diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 0000000..027bdb1
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,14 @@
+# EditorConfig is awesome: https://EditorConfig.org
+
+root = true
+
+[*]
+end_of_line = lf
+insert_final_newline = true
+trim_trailing_whitespace = true
+
+[*.py]
+charset = utf-8
+indent_style = space
+indent_size = 4
+max_line_length = 88
diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs
new file mode 100644
index 0000000..befa060
--- /dev/null
+++ b/.git-blame-ignore-revs
@@ -0,0 +1,5 @@
+# Run code through yapf
+19a821d5f1ff9079f9a40d27553182a433a27834
+
+# Run code through black
+0d9e3581d57f376865f49ae62fe9171789beca56
diff --git a/.gitignore b/.gitignore
index 029341d..b521867 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,90 +1,49 @@
+#
+# OS-specific
+#
+
 .DS_Store
-# Byte-compiled / optimized / DLL files
-__pycache__/
+
+#
+# Language specific
+#
+
+# Python
 *.py[cod]
-*$py.class
-
-# C extensions
-*.so
-
-# Distribution / packaging
-.Python
-env/
-build/
-develop-eggs/
-dist/
-downloads/
-eggs/
-.eggs/
-lib/
-lib64/
-parts/
-sdist/
-var/
 *.egg-info/
-.installed.cfg
-*.egg
-
-# PyInstaller
-#  Usually these files are written by a python script from a template
-#  before PyInstaller builds the exe, so as to inject date/other infos into it.
-*.manifest
-*.spec
-
-# Installer logs
-pip-log.txt
-pip-delete-this-directory.txt
-
-# Unit test / coverage reports
-htmlcov/
-.tox/
-.coverage
-.coverage.*
-.cache
-nosetests.xml
-coverage.xml
-*,cover
-.hypothesis/
-
-# Translations
-*.mo
-*.pot
-
-# Django stuff:
-*.log
-local_settings.py
-
-# Sphinx documentation
-docs/_build/
-
-# PyBuilder
-target/
-
-#Ipython Notebook
-.ipynb_checkpoints
-
-# pyenv
-.python-version
-
-# PyCharm
-.idea/
-
-# IntelliJ
-*.iml
-
-# VSCode
-/.vscode
-
-# Python virtual environment
+/build/
 /.venv
+/.mypy_cache
 
-# antlion configuration files
+#
+# Editors
+#
+
+/.idea/
+/.vscode/
+*~
+
+#
+# antlion
+#
+
+# Configuration
 /*.json
 /*.yaml
 /config/
 
-# antlion runtime files
+# Generated during run-time
 /logs
 
 # Local development scripts
 /*.sh
+!/format.sh
+
+#
+# third_party
+#
+
+/third_party/*
+!/third_party/github.com/
+!/third_party/github.com/jd/tenacity
+/third_party/github.com/jd/tenacity/src
diff --git a/BUILD.gn b/BUILD.gn
new file mode 100644
index 0000000..582d5b1
--- /dev/null
+++ b/BUILD.gn
@@ -0,0 +1,221 @@
+# Copyright 2023 The Fuchsia Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+# Declare Fuchsia build targets for using antlion from the Fuchsia tree.
+# Requires additional configuration of jiri fetch attributes from your Fuchsia
+# checkout:
+#   `jiri init -fetch-optional=antlion`
+
+import("//build/python/python_library.gni")
+
+assert(is_host, "antlion only supported on the host toolchain")
+
+# Tests for full build validation
+group("e2e_tests") {
+  testonly = true
+  public_deps = [ "tests:e2e_tests" ]
+}
+
+# Subset of tests to validate builds in under 15 minutes.
+group("e2e_tests_quick") {
+  testonly = true
+  public_deps = [ "tests:e2e_tests_quick" ]
+}
+
+# Tests for at-desk custom validation
+group("e2e_tests_manual") {
+  testonly = true
+  public_deps = [ "tests:e2e_tests_manual" ]
+}
+
+# Tests to validate the netstack in under 15 minutes.
+group("e2e_tests_netstack_quick") {
+  testonly = true
+  public_deps = [
+    "tests/dhcp:dhcpv4_duplicate_address_test",
+    "tests/dhcp:dhcpv4_interop_basic_test",
+    "tests/dhcp:dhcpv4_interop_combinatorial_options_test",
+    "tests/wlan/functional:beacon_loss_test",
+    "tests/wlan/performance:channel_sweep_test_quick",
+
+    # TODO(http://b/372467106): Uncomment once ToggleWlanInterfaceStressTest is
+    # updated to use current Fuchsia APIs for removing interfaces.
+    # "tests/netstack:toggle_wlan_interface_stress_test",
+  ]
+}
+
+# Unit tests only
+group("tests") {
+  testonly = true
+  public_deps = [ "runner:tests" ]
+}
+
+python_library("antlion") {
+  enable_mypy = false
+  source_root = "//third_party/antlion/packages/antlion"
+  testonly = true
+  sources = [
+    "__init__.py",
+    "base_test.py",
+    "capabilities/__init__.py",
+    "capabilities/ssh.py",
+    "context.py",
+    "controllers/__init__.py",
+    "controllers/access_point.py",
+    "controllers/adb.py",
+    "controllers/adb_lib/__init__.py",
+    "controllers/adb_lib/error.py",
+    "controllers/android_device.py",
+    "controllers/android_lib/__init__.py",
+    "controllers/android_lib/errors.py",
+    "controllers/android_lib/events.py",
+    "controllers/android_lib/logcat.py",
+    "controllers/android_lib/services.py",
+    "controllers/ap_lib/__init__.py",
+    "controllers/ap_lib/ap_get_interface.py",
+    "controllers/ap_lib/ap_iwconfig.py",
+    "controllers/ap_lib/bridge_interface.py",
+    "controllers/ap_lib/dhcp_config.py",
+    "controllers/ap_lib/dhcp_server.py",
+    "controllers/ap_lib/extended_capabilities.py",
+    "controllers/ap_lib/hostapd.py",
+    "controllers/ap_lib/hostapd_ap_preset.py",
+    "controllers/ap_lib/hostapd_bss_settings.py",
+    "controllers/ap_lib/hostapd_config.py",
+    "controllers/ap_lib/hostapd_constants.py",
+    "controllers/ap_lib/hostapd_security.py",
+    "controllers/ap_lib/hostapd_utils.py",
+    "controllers/ap_lib/radio_measurement.py",
+    "controllers/ap_lib/radvd.py",
+    "controllers/ap_lib/radvd_config.py",
+    "controllers/ap_lib/radvd_constants.py",
+    "controllers/ap_lib/regulatory_channels.py",
+    "controllers/ap_lib/third_party_ap_profiles/__init__.py",
+    "controllers/ap_lib/third_party_ap_profiles/actiontec.py",
+    "controllers/ap_lib/third_party_ap_profiles/asus.py",
+    "controllers/ap_lib/third_party_ap_profiles/belkin.py",
+    "controllers/ap_lib/third_party_ap_profiles/linksys.py",
+    "controllers/ap_lib/third_party_ap_profiles/netgear.py",
+    "controllers/ap_lib/third_party_ap_profiles/securifi.py",
+    "controllers/ap_lib/third_party_ap_profiles/tplink.py",
+    "controllers/ap_lib/wireless_network_management.py",
+    "controllers/attenuator.py",
+    "controllers/attenuator_lib/__init__.py",
+    "controllers/attenuator_lib/_tnhelper.py",
+    "controllers/attenuator_lib/aeroflex/__init__.py",
+    "controllers/attenuator_lib/aeroflex/telnet.py",
+    "controllers/attenuator_lib/minicircuits/__init__.py",
+    "controllers/attenuator_lib/minicircuits/http.py",
+    "controllers/attenuator_lib/minicircuits/telnet.py",
+    "controllers/fastboot.py",
+    "controllers/fuchsia_device.py",
+    "controllers/fuchsia_lib/__init__.py",
+    "controllers/fuchsia_lib/base_lib.py",
+    "controllers/fuchsia_lib/ffx.py",
+    "controllers/fuchsia_lib/lib_controllers/__init__.py",
+    "controllers/fuchsia_lib/lib_controllers/wlan_controller.py",
+    "controllers/fuchsia_lib/lib_controllers/wlan_policy_controller.py",
+    "controllers/fuchsia_lib/package_server.py",
+    "controllers/fuchsia_lib/sl4f.py",
+    "controllers/fuchsia_lib/ssh.py",
+    "controllers/fuchsia_lib/wlan_deprecated_configuration_lib.py",
+    "controllers/iperf_client.py",
+    "controllers/iperf_server.py",
+    "controllers/openwrt_ap.py",
+    "controllers/openwrt_lib/__init__.py",
+    "controllers/openwrt_lib/network_const.py",
+    "controllers/openwrt_lib/network_settings.py",
+    "controllers/openwrt_lib/openwrt_constants.py",
+    "controllers/openwrt_lib/wireless_config.py",
+    "controllers/openwrt_lib/wireless_settings_applier.py",
+    "controllers/packet_capture.py",
+    "controllers/pdu.py",
+    "controllers/pdu_lib/__init__.py",
+    "controllers/pdu_lib/digital_loggers/__init__.py",
+    "controllers/pdu_lib/digital_loggers/webpowerswitch.py",
+    "controllers/pdu_lib/synaccess/__init__.py",
+    "controllers/pdu_lib/synaccess/np02b.py",
+    "controllers/sl4a_lib/__init__.py",
+    "controllers/sl4a_lib/error_reporter.py",
+    "controllers/sl4a_lib/event_dispatcher.py",
+    "controllers/sl4a_lib/rpc_client.py",
+    "controllers/sl4a_lib/rpc_connection.py",
+    "controllers/sl4a_lib/sl4a_manager.py",
+    "controllers/sl4a_lib/sl4a_ports.py",
+    "controllers/sl4a_lib/sl4a_session.py",
+    "controllers/sniffer.py",
+    "controllers/sniffer_lib/__init__.py",
+    "controllers/sniffer_lib/local/__init__.py",
+    "controllers/sniffer_lib/local/local_base.py",
+    "controllers/sniffer_lib/local/tcpdump.py",
+    "controllers/sniffer_lib/local/tshark.py",
+    "controllers/utils_lib/__init__.py",
+    "controllers/utils_lib/commands/__init__.py",
+    "controllers/utils_lib/commands/command.py",
+    "controllers/utils_lib/commands/date.py",
+    "controllers/utils_lib/commands/ip.py",
+    "controllers/utils_lib/commands/journalctl.py",
+    "controllers/utils_lib/commands/nmcli.py",
+    "controllers/utils_lib/commands/pgrep.py",
+    "controllers/utils_lib/commands/route.py",
+    "controllers/utils_lib/commands/shell.py",
+    "controllers/utils_lib/commands/tcpdump.py",
+    "controllers/utils_lib/ssh/__init__.py",
+    "controllers/utils_lib/ssh/connection.py",
+    "controllers/utils_lib/ssh/formatter.py",
+    "controllers/utils_lib/ssh/settings.py",
+    "decorators.py",
+    "error.py",
+    "event/__init__.py",
+    "event/decorators.py",
+    "event/event.py",
+    "event/event_bus.py",
+    "event/event_subscription.py",
+    "event/subscription_handle.py",
+    "keys.py",
+    "libs/__init__.py",
+    "libs/logging/__init__.py",
+    "libs/logging/log_stream.py",
+    "libs/ota/__init__.py",
+    "libs/ota/ota_runners/__init__.py",
+    "libs/ota/ota_runners/ota_runner.py",
+    "libs/ota/ota_runners/ota_runner_factory.py",
+    "libs/ota/ota_tools/__init__.py",
+    "libs/ota/ota_tools/adb_sideload_ota_tool.py",
+    "libs/ota/ota_tools/ota_tool.py",
+    "libs/ota/ota_tools/ota_tool_factory.py",
+    "libs/ota/ota_tools/update_device_ota_tool.py",
+    "libs/ota/ota_updater.py",
+    "libs/proc/__init__.py",
+    "libs/proc/job.py",
+    "libs/proc/process.py",
+    "logger.py",
+    "net.py",
+    "runner.py",
+    "test_utils/__init__.py",
+    "test_utils/abstract_devices/__init__.py",
+    "test_utils/abstract_devices/wlan_device.py",
+    "test_utils/abstract_devices/wmm_transceiver.py",
+    "test_utils/dhcp/__init__.py",
+    "test_utils/dhcp/base_test.py",
+    "test_utils/fuchsia/__init__.py",
+    "test_utils/fuchsia/wmm_test_cases.py",
+    "test_utils/net/__init__.py",
+    "test_utils/net/connectivity_const.py",
+    "test_utils/net/net_test_utils.py",
+    "test_utils/wifi/__init__.py",
+    "test_utils/wifi/base_test.py",
+    "test_utils/wifi/wifi_constants.py",
+    "test_utils/wifi/wifi_test_utils.py",
+    "types.py",
+    "utils.py",
+    "validation.py",
+  ]
+  library_deps = [
+    "third_party/github.com/jd/tenacity",
+    "//src/testing/end_to_end/honeydew",
+    "//third_party/mobly",
+    "//third_party/pyyaml:yaml",
+  ]
+}
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a9c7f67..0c36022 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -10,20 +10,79 @@
 
 ## [Unreleased]
 
-### Added
-
-### Changed
+[unreleased]: https://fuchsia.googlesource.com/antlion/+/refs/tags/v0.3.0..refs/heads/main
 
 ### Removed
 
-### Fixed
+- [BREAKING CHANGE] Support for Python 3.8, 3.9, and 3.10. The minimum supported
+version of Python is now 3.11. If running antlion as part of the Fuchsia tree,
+nothing is required; Python 3.11 is vendored with Fuchsia and will be found by
+GN. If running antlion out of tree, ensure your Python version is at least 3.11.
+- `WlanRvrTest` user params `debug_pre_traffic_cmd` and `debug_post_traffic_cmd`
 
-[unreleased]: https://fuchsia.googlesource.com/antlion/+/refs/tags/v0.2.0..refs/heads/main
+## [0.3.0] - 2023-05-17
 
-## [0.2.0] - 2022-01-03
+[0.3.0]: https://fuchsia.googlesource.com/antlion/+/refs/tags/v0.2.0..refs/tags/v0.3.0
+
+### Deprecated
+
+- **Support for ACTS JSON configs; instead, use Mobly YAML configs.** To
+ease this transition, upon running `act.py`, a compatible YAML config will be
+generated for you and placed next to your JSON config.
+- **The `act.py` binary; instead, invoke tests directly.** Upon running
+`act.py`, a deprecation warning will provide instructions for how to invoke
+antlion tests without act.py and with the newly generated YAML config.
 
 ### Added
 
+- Presubmit testing in [CV] (aka CQ). All tests specified with the `qemu_env`
+environment will run before every antlion CL is submitted.
+- Postsubmit testing in [CI]. See [Milo] for an exhaustive list of builders.
+- [EditorConfig] file for consistent coding styles.
+Installing an EditorConfig plugin for your editor is highly recommended.
+
+[CV]: https://chromium.googlesource.com/infra/luci/luci-go/+/refs/heads/main/cv/README.md
+[CI]: https://chromium.googlesource.com/chromium/src/+/master/docs/tour_of_luci_ui.md
+[Milo]: https://luci-milo.appspot.com/ui/search?q=antlion
+[EditorConfig]: https://editorconfig.org
+
+### Changed
+
+- Default test execution from ACTS to Mobly. `antlion_host_test()` now invokes
+the test file directly using the Mobly test runner, rather than using `act.py`.
+  - All tests have been refactored to allow direct running with the Mobly test
+  runner.
+  - `act.py` now converts ACTS JSON config to compatible Mobly YAML config. The
+  resulting config is passed directly to Mobly's config parser. See notes for
+  this release's deprecations above.
+- Generate YAML config instead of JSON config from antlion-runner.
+- `FuchsiaDevice.authorized_file_loc` config field is now optional. This field
+is only used during `FlashTest`; it is not used when the device is already
+provisioned (e.g. when tests are dispatched in Fuchsia infrastructure).
+
+### Removed
+
+- Unused controllers and tests (full list)
+
+### Fixed
+
+- Failure to stop session_manager using ffx in `WlanRebootTest` ([@patricklu],
+[bug](http://b/267330535))
+- Failure to parse 'test_name' in DHCP configuration file in `Dhcpv4InteropTest`
+(invalid option) introduced by previous refactor ([@patricklu],
+[bug](http://b/232574848))
+- Logging for `Dhcpv4InteropTest` changed to utilize a temp file instead of
+/var/log/messages to fix test error with duplicate PID log messages
+([@patricklu], [bug](http://b/232574848))
+
+## [0.2.0] - 2023-01-03
+
+[0.2.0]: https://fuchsia.googlesource.com/antlion/+/refs/tags/v0.1.0..refs/tags/v0.2.0
+
+### Added
+
+- Added snapshots before reboot and during test teardown in `WlanRebootTest`
+([@patricklu], [bug](http://b/273923552))
 - Download radvd logs from AP for debugging IPv6 address allocation
 - Optional `wlan_features` config field to `FuchsiaDevice` for declaring which
 WLAN features the device supports, such as BSS Transition Management
@@ -32,12 +91,12 @@
 
 - All path config options in `FuchsiaDevice` expand the home directory (`~`) and
 environmental variables
-	- Used by `ssh_priv_key`, `authorized_file_loc`, and `ffx_binary_path` for
-	sensible defaults using `$FUCHSIA_DIR`
+  - Used by `ssh_priv_key`, `authorized_file_loc`, and `ffx_binary_path` for
+  sensible defaults using `$FUCHSIA_DIR`
 - Running tests works out of the box without specifying `--testpaths`
-	- Moved `tests` and `unit_tests` to the `antlion` package, enabling
-	straight-forward packaging of tests.
-	- Merged `antlion` and `antlion_contrib` packages
+  - Moved `tests` and `unit_tests` to the `antlion` package, enabling
+  straight-forward packaging of tests.
+  - Merged `antlion` and `antlion_contrib` packages
 - Converted several required dependencies to optional dependencies:
   - `bokeh` is only needed for producing HTML graphing. If this feature is
   desired, install antlion with the bokeh option: `pip install ".[bokeh]"`
@@ -57,19 +116,19 @@
 - Failure to acquire IPv6 address in `WlanRebootTest` ([bug](http://b/256009189))
 - Typo in `ChannelSweepTest` preventing use of iPerf ([@patricklu])
 - "Country code never updated" error affecting all Fuchsia ToT builds
-([@karlward], [bug](https://fxbug.dev/116500))
+([@karlward], [bug](https://fxbug.dev/42067674))
 - Parsing new stderr format from `ffx component destroy` ([@karlward],
-[bug](https://fxbug.dev/116544))
+[bug](https://fxbug.dev/42067722))
 - "Socket operation on non-socket" error during initialization of ffx on MacOS
-([@karlward], [bug](https://fxbug.dev/116626))
+([@karlward], [bug](https://fxbug.dev/42067812))
 - Python 3.8 support for IPv6 scope IDs ([bug](http://b/261746355))
 
-[0.2.0]: https://fuchsia.googlesource.com/antlion/+/refs/tags/v0.1.0..refs/tags/v0.2.0
-
 ## [0.1.0] - 2022-11-28
 
 Forked from ACTS with the following changes
 
+[0.1.0]: https://fuchsia.googlesource.com/antlion/+/refs/tags/v0.1.0
+
 ### Added
 
 - A modern approach to installation using `pyproject.toml` via `pip install .`
@@ -80,6 +139,8 @@
 - Package and import names from ACTS to antlion
 - Copyright notice from AOSP to Fuchsia Authors
 
+[src-layout]: https://setuptools.pypa.io/en/latest/userguide/package_discovery.html#src-layout
+
 ### Deprecated
 
 - Use of the `setup.py` script. This is only used to keep infrastructure
@@ -98,9 +159,6 @@
 - KeyError for 'mac_addr' in WlanDeprecatedConfigurationTest ([@sakuma],
 [bug](http://b/237709921))
 
-[0.1.0]: https://fuchsia.googlesource.com/antlion/+/refs/tags/v0.1.0
-[src-layout]: https://setuptools.pypa.io/en/latest/userguide/package_discovery.html#src-layout
-
 [@sakuma]: https://fuchsia-review.git.corp.google.com/q/owner:sakuma%2540google.com
 [@patricklu]: https://fuchsia-review.git.corp.google.com/q/owner:patricklu%2540google.com
 [@karlward]: https://fuchsia-review.git.corp.google.com/q/owner:karlward%2540google.com
diff --git a/MANIFEST.in b/MANIFEST.in
index a8ad1bb..a6caf7f 100644
--- a/MANIFEST.in
+++ b/MANIFEST.in
@@ -1,4 +1,4 @@
 include setup.py README.md
-recursive-include src/antlion *
+recursive-include packages/antlion *
 global-exclude .DS_Store
 global-exclude *.pyc
diff --git a/README.md b/README.md
index be529cf..74c5a6d 100644
--- a/README.md
+++ b/README.md
@@ -7,13 +7,91 @@
 
 [TOC]
 
-[Docs]: http://go/fxca
+[Docs]: http://go/antlion
 [Report Bug]: http://go/conn-test-bug
 [Request Feature]: http://b/issues/new?component=1182297&template=1680893
 
-## Getting Started
+## Getting started with QEMU
 
-Requires Python 3.8+
+The quickest way to run antlion is by using the Fuchsia QEMU emulator. This
+enables antlion tests that do not require hardware-specific capabilities like
+WLAN. This is especially useful to verify if antlion builds and runs without
+syntax errors. If you require WLAN capabilities, see
+[below](#running-with-a-local-physical-device).
+
+1. [Checkout Fuchsia](https://fuchsia.dev/fuchsia-src/get-started/get_fuchsia_source)
+
+2. Configure and build Fuchsia to run antlion tests virtually on QEMU
+
+   ```sh
+   fx set core.qemu-x64 \
+      --with //src/testing/sl4f \
+      --with //src/sys/bin/start_sl4f \
+      --args 'core_realm_shards += [ "//src/testing/sl4f:sl4f_core_shard" ]' \
+      --with-host //third_party/antlion:e2e_tests_quick
+   fx build
+   ```
+
+3. In a separate terminal, run the emulator with networking enabled
+
+   ```sh
+   ffx emu stop && ffx emu start -H --net tap && ffx log
+   ```
+
+4. In a separate terminal, run a package server
+
+   ```sh
+   fx serve
+   ```
+
+5. Run an antlion test
+
+   ```sh
+   fx test --e2e --output //third_party/antlion/tests/examples:sl4f_sanity_test
+   ```
+
+## Running with a local physical device
+
+A physical device is required for most antlion tests, which rely on physical I/O
+such as WLAN and Bluetooth. Antlion is designed to make testing physical devices
+as easy, reliable, and reproducible as possible. The device will be discovered
+using mDNS, so make sure your host machine has a network connection to the
+device.
+
+1. Configure and build Fuchsia for your target with the following extra
+   arguments:
+
+   ```sh
+   fx set core.my-super-cool-product \
+      --with //src/testing/sl4f \
+      --with //src/sys/bin/start_sl4f \
+      --args='core_realm_shards += [ "//src/testing/sl4f:sl4f_core_shard" ]' \
+      --with-host //third_party/antlion:e2e_tests
+   fx build
+   ```
+
+2. Flash your device with the new build
+
+3. In a separate terminal, run a package server
+
+   ```sh
+   fx serve
+   ```
+
+4. Run an antlion test
+
+   ```sh
+   fx test --e2e --output //third_party/antlion/tests/functional:ping_stress_test
+   ```
+
+> Local auxiliary devices are not yet support by `antlion-runner`, which is
+> responsible for generating Mobly configs. In the meantime, see the
+> section below for manually crafting Mobly configs to support auxiliary
+> devices.
+
+## Running without a Fuchsia checkout
+
+Requires Python 3.11+
 
 1. Clone the repo
 
@@ -25,52 +103,81 @@
 
    ```sh
    cd antlion
-   python3 -m venv .venv  # creates a "virtual environment" in the `.venv` directory
-   source .venv/bin/activate  # activates the virtual environment. Run `deactivate` to exit it later
-   pip install --editable ".[dev,test]"
+   python3 -m venv .venv      # Create a virtual environment in the `.venv` directory
+   source .venv/bin/activate  # Activate the virtual environment
+   pip install --editable ".[mdns]"
+   # Run `deactivate` later to exit the virtual environment
    ```
 
 3. Write the sample config and update the Fuchsia controller to match your
    development environment
 
    ```sh
-   mkdir -p config
-   cat <<EOF > config/simple.json
-   {
-      "testbed": [{
-         "name": "simple_testbed",
-         "FuchsiaDevice": [{
-            "ip": "fuchsia-00e0-4c01-04df"
-         }]
-      }],
-      "logpath": "logs"
-   }
+   cat <<EOF > simple-config.yaml
+   TestBeds:
+   - Name: antlion-runner
+     Controllers:
+       FuchsiaDevice:
+       - ip: fuchsia-00e0-4c01-04df
+   MoblyParams:
+     LogPath: logs
    EOF
    ```
 
+   Replace `fuchsia-00e0-4c01-04df` with your device's nodename, or
+   `fuchsia-emulator` if using an emulator. The nodename can be found by looking
+   for a log similar to the one below.
+
+   ```text
+   [0.524][klog][klog][I] netsvc: nodename='fuchsia-emulator'
+   ```
+
 4. Run the sanity test
 
    ```sh
-   antlion -c config/simple.json -tc Sl4fSanityTest
+   python tests/examples/Sl4fSanityTest.py -c simple-config.yaml
    ```
 
-See `antlion -h` for more full usage.
-
 ## Contributing
 
-Contributions are what make open source a great place to learn, inspire, and
-create. Any contributions you make are **greatly appreciated**.
+Contributions are what make open source projects a great place to learn,
+inspire, and create. Any contributions you make are **greatly appreciated**.
+If you have a suggestion that would make this better, please create a CL.
 
-If you have a suggestion that would make this better, please create a pull
-request.
+Before contributing, additional setup is necessary:
 
-1. Create a feature branch (`git checkout -b feature/amazing-feature`)
-2. Document your change in `CHANGELOG.md`
-3. Commit changes (`git commit -m 'Add some amazing feature'`)
-4. Upload CL (`git push origin HEAD:refs/for/main`)
+- Install developer Python packages for formatting and linting
+
+  ```sh
+  pip install --editable ".[dev]"
+  ```
+
+- Install an [EditorConfig](https://editorconfig.org/) plugin for consistent
+  whitespace
+
+- Complete the steps in '[Contribute source changes]' to gain authorization to
+  upload CLs to Fuchsia's Gerrit.
+
+To create a CL:
+
+1. Create a branch (`git checkout -b feature/amazing-feature`)
+2. Make changes
+3. Document the changes in `CHANGELOG.md`
+4. Auto-format changes (`./format.sh`)
+
+   > Note: antlion follows the [Black code style] (rather than the
+   > [Google Python Style Guide])
+
+5. Verify no typing errors (`mypy .`)
+6. Commit changes (`git add . && git commit -m 'Add some amazing feature'`)
+7. Upload CL (`git push origin HEAD:refs/for/main`)
 
 > A public bug tracker is not (yet) available.
 
+[Black code style]: https://black.readthedocs.io/en/stable/the_black_code_style/current_style.html
+[Google Python Style Guide]: https://google.github.io/styleguide/pyguide.html
+[Contribute source changes]: https://fuchsia.dev/fuchsia-src/development/source_code/contribute_changes#prerequisites
+
 ### Recommended git aliases
 
 There are a handful of git commands that will be commonly used throughout the
@@ -87,6 +194,13 @@
   uc = push origin HEAD:refs/for/main%l=Commit-Queue+1,l=Fuchsia-Auto-Submit+1,publish-comments,r=sbalana
 ```
 
+You may also want to add a section to ignore the project's large formatting changes:
+
+```gitconfig
+[blame]
+  ignoreRevsFile = .git-blame-ignore-revs
+```
+
 ## License
 
 Distributed under the Apache 2.0 License. See `LICENSE` for more information.
diff --git a/antlion_host_test.gni b/antlion_host_test.gni
new file mode 100644
index 0000000..5593226
--- /dev/null
+++ b/antlion_host_test.gni
@@ -0,0 +1,197 @@
+# Copyright 2024 The Fuchsia Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+import("//build/host.gni")
+import("//build/python/python_binary.gni")
+import("//build/rust/rustc_binary.gni")
+import("//build/testing/host_test.gni")
+import("//build/testing/host_test_data.gni")
+
+# Declares a host-side antlion test.
+#
+# Examples
+#
+# ```
+# antlion_host_test("sl4f_sanity_test") {
+#   main_source = "Sl4fSanityTest.py"
+# }
+#
+# antlion_host_test("wlan_rvr_test_2g") {
+#   main_source = "WlanRvrTest.py"
+#   test_params = "rvr_settings.yaml"
+#   test_cases = [ "test_rvr_11n_2g_*" ]
+# }
+# ```
+#
+# Parameters
+#
+#  main_source
+#    The .py file defining the antlion test.
+#    Type: path
+#
+#  sources (optional)
+#    Other files that are used in the test.
+#    Type: list(path)
+#    Default: empty list
+#
+#  test_params (optional)
+#    Path to a YAML file with additional test parameters. This will be provided
+#    to the test in the antlion config under the "test_params" key.
+#    Type: string
+#
+#  test_cases (optional)
+#    List of test cases to run. Defaults to running all test cases.
+#    Type: list(string)
+#
+#  test_data_deps (optional)
+#    List of test data GN targets that are needed at runtime.
+#    Type: list(string)
+#    Default: empty list
+#
+#   deps
+#   environments
+#   visibility
+template("antlion_host_test") {
+  assert(defined(invoker.main_source), "main_source is required")
+
+  #
+  # Define antlion test python_binary().
+  #
+  _python_binary_name = "${target_name}.pyz"
+  _python_binary_target = "${target_name}_python_binary"
+  python_binary(_python_binary_target) {
+    forward_variables_from(invoker,
+                           [
+                             "enable_mypy",
+                             "main_source",
+                             "sources",
+                             "data_sources",
+                             "data_package_name",
+                           ])
+    output_name = _python_binary_name
+    main_callable = "test_runner.main"  # Mobly-specific entry point.
+    deps = [ "//third_party/antlion" ]
+    if (defined(invoker.test_data_deps)) {
+      deps += invoker.test_data_deps
+    }
+    if (defined(invoker.libraries)) {
+      deps += invoker.libraries
+    }
+    testonly = true
+    visibility = [ ":*" ]
+  }
+
+  _test_dir = "${root_out_dir}/test_data/" + get_label_info(target_name, "dir")
+
+  #
+  # Define antlion test host_test_data().
+  #
+  _host_test_data_target = "${target_name}_test_data"
+  host_test_data(_host_test_data_target) {
+    testonly = true
+    visibility = [ ":*" ]
+    sources = [ get_label_info(":${_python_binary_target}", "target_out_dir") +
+                "/${_python_binary_name}" ]
+    outputs = [ "${_test_dir}/${_python_binary_name}" ]
+    deps = [ ":${_python_binary_target}" ]
+    if (defined(invoker.deps)) {
+      deps += invoker.deps
+    }
+  }
+
+  #
+  # Define SSH binary host_test_data().
+  #
+  _host_test_data_ssh = "${target_name}_test_data_ssh"
+  host_test_data(_host_test_data_ssh) {
+    testonly = true
+    visibility = [ ":*" ]
+    sources = [
+      "//prebuilt/third_party/openssh-portable/${host_os}-${host_cpu}/bin/ssh",
+    ]
+    outputs = [ "${_test_dir}/ssh" ]
+  }
+
+  #
+  # Define Mobly test params YAML host_test_data().
+  #
+  if (defined(invoker.test_params)) {
+    _host_test_data_test_params = "${target_name}_test_data_test_params"
+    host_test_data(_host_test_data_test_params) {
+      testonly = true
+      visibility = [ ":*" ]
+      sources = [ invoker.test_params ]
+      outputs = [ "${_test_dir}/${invoker.test_params}" ]
+    }
+  }
+
+  #
+  # Define FFX binary host_test_data().
+  #
+  _host_test_data_ffx = "${target_name}_test_data_ffx"
+  host_test_data(_host_test_data_ffx) {
+    testonly = true
+    visibility = [ ":*" ]
+    sources = [ get_label_info("//src/developer/ffx", "root_out_dir") + "/ffx" ]
+    outputs = [ "${_test_dir}/ffx" ]
+    deps = [ "//src/developer/ffx:ffx_bin($host_toolchain)" ]
+  }
+
+  #
+  # Define the antlion host_test() using antlion-runner.
+  #
+  host_test(target_name) {
+    forward_variables_from(invoker,
+                           [
+                             "environments",
+                             "visibility",
+                             "isolated",
+                             "product_bundle",
+                           ])
+
+    binary_path = "${root_out_dir}/antlion-runner"
+
+    args = [
+      "--python-bin",
+      rebase_path(python_exe_src, root_build_dir),
+      "--antlion-pyz",
+      rebase_path("${_test_dir}/${_python_binary_name}", root_build_dir),
+      "--out-dir",
+      rebase_path("${_test_dir}", root_build_dir),
+      "--ffx-binary",
+      rebase_path("${_test_dir}/ffx", root_build_dir),
+      "--ffx-subtools-search-path",
+      rebase_path(host_tools_dir, root_build_dir),
+      "--ssh-binary",
+      rebase_path("${_test_dir}/ssh", root_build_dir),
+    ]
+
+    if (defined(invoker.test_cases)) {
+      args += invoker.test_cases
+    }
+
+    data_deps = [ "//src/developer/ffx:suite_test_data" ]
+
+    deps = [
+      ":${_host_test_data_ffx}",
+      ":${_host_test_data_ssh}",
+      ":${_host_test_data_target}",
+      "//build/python:interpreter",
+      "//src/testing/end_to_end/honeydew",
+      "//third_party/antlion/runner",
+    ]
+
+    if (defined(invoker.test_params)) {
+      args += [
+        "--test-params",
+        rebase_path("${_test_dir}/${invoker.test_params}", root_build_dir),
+      ]
+      deps += [ ":${_host_test_data_test_params}" ]
+    }
+
+    if (defined(invoker.test_data_deps)) {
+      deps += invoker.test_data_deps
+    }
+  }
+}
diff --git a/environments.gni b/environments.gni
new file mode 100644
index 0000000..d19b903
--- /dev/null
+++ b/environments.gni
@@ -0,0 +1,188 @@
+# Copyright 2023 The Fuchsia Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+import("//build/testing/environments.gni")
+
+astro_ap_env = {
+  dimensions = {
+    access_points = "1"
+    device_type = "Astro"
+    pool = "fuchsia.tests.connectivity"
+  }
+  tags = [ "antlion" ]
+}
+
+astro_ap_iperf_env = {
+  dimensions = {
+    access_points = "1"
+    device_type = "Astro"
+    iperf_servers = "1"
+    pool = "fuchsia.tests.connectivity"
+  }
+  tags = [ "antlion" ]
+}
+
+astro_ap_iperf_attenuator_env = {
+  dimensions = {
+    access_points = "1"
+    attenuators = "1"
+    device_type = "Astro"
+    iperf_servers = "1"
+    pool = "fuchsia.tests.connectivity"
+  }
+  tags = [ "antlion" ]
+}
+
+sherlock_ap_env = {
+  dimensions = {
+    access_points = "1"
+    device_type = "Sherlock"
+    pool = "fuchsia.tests.connectivity"
+  }
+  tags = [ "antlion" ]
+}
+
+sherlock_ap_iperf_env = {
+  dimensions = {
+    access_points = "1"
+    device_type = "Sherlock"
+    iperf_servers = "1"
+    pool = "fuchsia.tests.connectivity"
+  }
+  tags = [ "antlion" ]
+}
+
+sherlock_ap_iperf_attenuator_env = {
+  dimensions = {
+    access_points = "1"
+    attenuators = "1"
+    device_type = "Sherlock"
+    iperf_servers = "1"
+    pool = "fuchsia.tests.connectivity"
+  }
+  tags = [ "antlion" ]
+}
+
+nelson_ap_env = {
+  dimensions = {
+    access_points = "1"
+    device_type = "Nelson"
+    pool = "fuchsia.tests.connectivity"
+  }
+  tags = [ "antlion" ]
+}
+
+nelson_ap_iperf_env = {
+  dimensions = {
+    access_points = "1"
+    device_type = "Nelson"
+    iperf_servers = "1"
+    pool = "fuchsia.tests.connectivity"
+  }
+  tags = [ "antlion" ]
+}
+
+nelson_ap_iperf_attenuator_env = {
+  dimensions = {
+    access_points = "1"
+    attenuators = "1"
+    device_type = "Nelson"
+    iperf_servers = "1"
+    pool = "fuchsia.tests.connectivity"
+  }
+  tags = [ "antlion" ]
+}
+
+nuc11_ap_env = {
+  dimensions = {
+    access_points = "1"
+    device_type = "Intel NUC Kit NUC11TNHv5"
+    pool = "fuchsia.tests.connectivity"
+  }
+  tags = [ "antlion" ]
+}
+
+nuc11_ap_iperf_env = {
+  dimensions = {
+    access_points = "1"
+    device_type = "Intel NUC Kit NUC11TNHv5"
+    iperf_servers = "1"
+    pool = "fuchsia.tests.connectivity"
+  }
+  tags = [ "antlion" ]
+}
+
+nuc11_ap_iperf_attenuator_env = {
+  dimensions = {
+    access_points = "1"
+    attenuators = "1"
+    device_type = "Intel NUC Kit NUC11TNHv5"
+    iperf_servers = "1"
+    pool = "fuchsia.tests.connectivity"
+  }
+  tags = [ "antlion" ]
+}
+
+vim3_ap_env = {
+  dimensions = {
+    access_points = "1"
+    device_type = "Vim3"
+    pool = "fuchsia.tests.connectivity"
+  }
+  tags = [ "antlion" ]
+}
+
+vim3_ap_iperf_env = {
+  dimensions = {
+    access_points = "1"
+    device_type = "Vim3"
+    iperf_servers = "1"
+    pool = "fuchsia.tests.connectivity"
+  }
+  tags = [ "antlion" ]
+}
+
+vim3_ap_iperf_attenuator_env = {
+  dimensions = {
+    access_points = "1"
+    attenuators = "1"
+    device_type = "Vim3"
+    iperf_servers = "1"
+    pool = "fuchsia.tests.connectivity"
+  }
+  tags = [ "antlion" ]
+}
+
+# Display environments supported by antlion.
+display_envs = [
+  astro_env,
+  sherlock_env,
+  nelson_env,
+  nuc11_env,
+  vim3_env,
+]
+
+display_ap_envs = [
+  astro_ap_env,
+  sherlock_ap_env,
+  nelson_ap_env,
+  nuc11_ap_env,
+  vim3_ap_env,
+]
+
+display_ap_iperf_envs = [
+  astro_ap_iperf_env,
+  sherlock_ap_iperf_env,
+  nelson_ap_iperf_env,
+  nuc11_ap_iperf_env,
+  vim3_ap_iperf_env,
+]
+
+display_ap_iperf_attenuator_envs = [
+  astro_ap_iperf_attenuator_env,
+  sherlock_ap_iperf_attenuator_env,
+  nelson_ap_iperf_attenuator_env,
+  nuc11_ap_iperf_attenuator_env,
+  vim3_ap_iperf_attenuator_env,
+]
diff --git a/format.sh b/format.sh
new file mode 100755
index 0000000..8ede1f6
--- /dev/null
+++ b/format.sh
@@ -0,0 +1,97 @@
+#!/bin/bash
+
+# Get the directory of this script
+SCRIPT_DIR="$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")"
+
+install_virtual_environment_doc() {
+    echo "Please install the virtual environment before running format.sh by running"
+    echo "the following commands:"
+    echo ""
+    echo "  cd $SCRIPT_DIR"
+    echo "  python3 -m venv .venv"
+    echo "  (source .venv/bin/activate && pip install -e \".[dev]\")"
+}
+
+if [ -f "$SCRIPT_DIR/.venv/bin/activate" ] ; then
+    source "$SCRIPT_DIR/.venv/bin/activate"
+else
+    echo ""
+    echo "====================="
+    echo "Error: Virtual environment not installed!"
+    echo "====================="
+    echo ""
+    install_virtual_environment_doc
+    echo ""
+    exit 1
+fi
+
+# Verify expected virtual environment binaries exist to prevent unintentionally running
+# different versions from outside the environment.
+#
+# Note: The virtual environment may exist without the binaries if dependencies weren't installed
+# (e.g., running `python3 -m venv .venv` without `pip install -e '.[dev]'`).
+find_venv_binary() {
+    find .venv/bin -name $1 | grep -q .
+}
+
+venv_binaries="autoflake black isort"
+all_binaries_found=true
+
+for binary in $venv_binaries; do
+    if ! find_venv_binary $binary; then
+        all_binaries_found=false
+        echo "Error: $binary not installed in virtual environment"
+    fi
+done
+
+if ! $all_binaries_found; then
+    echo ""
+    install_virtual_environment_doc
+    echo ""
+    exit 1
+fi
+
+# Detect trivial unused code.
+#
+# Automatically removal is possible, but is considered an unsafe operation. When a
+# change hasn't been commited, automatic removal could cause unintended irreversible
+# loss of in-progress code.
+#
+# Note: This cannot detect unused code between modules or packages. For complex unused
+# code detection, vulture should be used.
+autoflake \
+    --quiet \
+    --check-diff \
+    --remove-duplicate-keys \
+    --remove-unused-variables \
+    --remove-all-unused-imports \
+    --recursive .
+
+if [ $? -eq 0 ]; then
+    echo "No unused code found"
+else
+    echo ""
+    echo "====================="
+    echo "Unused code detected!"
+    echo "====================="
+    echo ""
+    echo "If these changes are trivial, consider running:"
+    echo "\"autoflake --in-place --remove-unused-variables --remove-all-unused-imports -r .\""
+    echo ""
+    read -p "Run this command to remove all unused code? [y/n] " -n 1 -r
+    echo ""
+    echo ""
+
+    if [[ $REPLY =~ ^[Yy]$ ]]; then
+        autoflake --in-place --remove-unused-variables --remove-all-unused-imports -r .
+    else
+        exit 1
+    fi
+fi
+
+# Sort imports to avoid bikeshedding.
+isort .
+
+# Format code; also to avoid bikeshedding.
+black .
+
diff --git a/src/antlion/__init__.py b/packages/antlion/__init__.py
similarity index 100%
rename from src/antlion/__init__.py
rename to packages/antlion/__init__.py
diff --git a/packages/antlion/base_test.py b/packages/antlion/base_test.py
new file mode 100755
index 0000000..9e539ca
--- /dev/null
+++ b/packages/antlion/base_test.py
@@ -0,0 +1,73 @@
+#!/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 inspect
+import re
+from typing import Callable
+
+from mobly.base_test import BaseTestClass
+from mobly.base_test import Error as MoblyError
+
+
+class AntlionBaseTest(BaseTestClass):
+    # TODO(https://github.com/google/mobly/issues/887): Remove this once similar
+    # functionality is merged into Mobly.
+    def _get_test_methods(
+        self, test_names: list[str]
+    ) -> list[tuple[str, Callable[[], None]]]:
+        """Resolves test method names to bound test methods.
+
+        Args:
+            test_names: Test method names.
+
+        Returns:
+            List of tuples containing the test method name and the function implementing
+            its logic.
+
+        Raises:
+            MoblyError: test_names does not match any tests.
+        """
+
+        test_table: dict[str, Callable[[], None]] = {**self._generated_test_table}
+        for name, _ in inspect.getmembers(type(self), callable):
+            if name.startswith("test_"):
+                test_table[name] = getattr(self, name)
+
+        test_methods: list[tuple[str, Callable[[], None]]] = []
+        for test_name in test_names:
+            if test_name in test_table:
+                test_methods.append((test_name, test_table[test_name]))
+            else:
+                try:
+                    pattern = re.compile(test_name)
+                except Exception as e:
+                    raise MoblyError(
+                        f'"{test_name}" is not a valid regular expression'
+                    ) from e
+                for name in test_table:
+                    if pattern.fullmatch(name.strip()):
+                        test_methods.append((name, test_table[name]))
+
+        if len(test_methods) == 0:
+            all_patterns = '" or "'.join(test_names)
+            all_tests = "\n - ".join(test_table.keys())
+            raise MoblyError(
+                f"{self.TAG} does not declare any tests matching "
+                f'"{all_patterns}". Please verify the correctness of '
+                f"{self.TAG} test names: \n - {all_tests}"
+            )
+
+        return test_methods
diff --git a/src/antlion/controllers/ap_lib/third_party_ap_profiles/__init__.py b/packages/antlion/capabilities/__init__.py
similarity index 100%
copy from src/antlion/controllers/ap_lib/third_party_ap_profiles/__init__.py
copy to packages/antlion/capabilities/__init__.py
diff --git a/packages/antlion/capabilities/ssh.py b/packages/antlion/capabilities/ssh.py
new file mode 100644
index 0000000..1dddd55
--- /dev/null
+++ b/packages/antlion/capabilities/ssh.py
@@ -0,0 +1,456 @@
+#!/usr/bin/env python3
+#
+# Copyright 2023 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.
+
+from __future__ import annotations
+
+import logging
+import os
+import shlex
+import shutil
+import signal
+import subprocess
+import time
+from dataclasses import dataclass
+from typing import IO, Mapping
+
+from mobly import logger, signals
+
+from antlion.net import wait_for_port
+from antlion.runner import CalledProcessError, CalledProcessTransportError, Runner
+from antlion.types import Json
+from antlion.validation import MapValidator
+
+DEFAULT_SSH_PORT: int = 22
+DEFAULT_SSH_TIMEOUT_SEC: float = 60.0
+DEFAULT_SSH_CONNECT_TIMEOUT_SEC: int = 90
+DEFAULT_SSH_SERVER_ALIVE_INTERVAL: int = 30
+# The default package repository for all components.
+
+
+class SSHResult:
+    """Result of an SSH command."""
+
+    def __init__(
+        self,
+        process: (
+            subprocess.CompletedProcess[bytes]
+            | subprocess.CompletedProcess[str]
+            | subprocess.CalledProcessError
+        ),
+    ) -> None:
+        if isinstance(process.stdout, bytes):
+            self._stdout_bytes = process.stdout
+        elif isinstance(process.stdout, str):
+            self._stdout = process.stdout
+        else:
+            raise TypeError(
+                "Expected process.stdout to be either bytes or str, "
+                f"got {type(process.stdout)}"
+            )
+
+        if isinstance(process.stderr, bytes):
+            self._stderr_bytes = process.stderr
+        elif isinstance(process.stderr, str):
+            self._stderr = process.stderr
+        else:
+            raise TypeError(
+                "Expected process.stderr to be either bytes or str, "
+                f"got {type(process.stderr)}"
+            )
+
+        self._exit_status = process.returncode
+
+    def __str__(self) -> str:
+        if self.exit_status == 0:
+            return self.stdout
+        return f'status {self.exit_status}, stdout: "{self.stdout}", stderr: "{self.stderr}"'
+
+    @property
+    def stdout(self) -> str:
+        if not hasattr(self, "_stdout"):
+            self._stdout = self._stdout_bytes.decode("utf-8", errors="replace")
+        return self._stdout
+
+    @property
+    def stdout_bytes(self) -> bytes:
+        if not hasattr(self, "_stdout_bytes"):
+            self._stdout_bytes = self._stdout.encode()
+        return self._stdout_bytes
+
+    @property
+    def stderr(self) -> str:
+        if not hasattr(self, "_stderr"):
+            self._stderr = self._stderr_bytes.decode("utf-8", errors="replace")
+        return self._stderr
+
+    @property
+    def exit_status(self) -> int:
+        return self._exit_status
+
+
+class SSHError(signals.TestError):
+    """A SSH command returned with a non-zero status code."""
+
+    def __init__(
+        self, command: list[str], result: CalledProcessError, elapsed_sec: float
+    ):
+        if result.returncode < 0:
+            try:
+                reason = f"died with {signal.Signals(-result.returncode)}"
+            except ValueError:
+                reason = f"died with unknown signal {-result.returncode}"
+        else:
+            reason = f"unexpectedly returned {result.returncode}"
+
+        super().__init__(
+            f'SSH command "{" ".join(command)}" {reason} after {elapsed_sec:.2f}s\n'
+            f'stderr: {result.stderr.decode("utf-8", errors="replace")}\n'
+            f'stdout: {result.stdout.decode("utf-8", errors="replace")}\n'
+        )
+        self.result = result
+
+
+@dataclass
+class SSHConfig:
+    """SSH client config."""
+
+    # SSH flags. See ssh(1) for full details.
+    user: str
+    host_name: str
+    identity_file: str
+
+    ssh_binary: str = "ssh"
+    config_file: str = "/dev/null"
+    port: int = 22
+
+    #
+    # SSH options. See ssh_config(5) for full details.
+    #
+    connect_timeout: int = DEFAULT_SSH_CONNECT_TIMEOUT_SEC
+    server_alive_interval: int = DEFAULT_SSH_SERVER_ALIVE_INTERVAL
+    strict_host_key_checking: bool = False
+    user_known_hosts_file: str = "/dev/null"
+    log_level: str = "ERROR"
+
+    # Force allocation of a pseudo-tty. This can be used to execute arbitrary
+    # screen-based programs on a remote machine, which can be very useful, e.g.
+    # when implementing menu services.
+    force_tty: bool = False
+
+    def full_command(self, command: list[str]) -> list[str]:
+        """Generate the complete command to execute command over SSH.
+
+        Args:
+            command: The command to run over SSH
+            force_tty: Force pseudo-terminal allocation. This can be used to
+                execute arbitrary screen-based programs on a remote machine,
+                which can be very useful, e.g. when implementing menu services.
+
+        Returns:
+            Arguments composing the complete call to SSH.
+        """
+        return [
+            self.ssh_binary,
+            # SSH flags
+            "-i",
+            self.identity_file,
+            "-F",
+            self.config_file,
+            "-p",
+            str(self.port),
+            # SSH configuration options
+            "-o",
+            f"ConnectTimeout={self.connect_timeout}",
+            "-o",
+            f"ServerAliveInterval={self.server_alive_interval}",
+            "-o",
+            f'StrictHostKeyChecking={"yes" if self.strict_host_key_checking else "no"}',
+            "-o",
+            f"UserKnownHostsFile={self.user_known_hosts_file}",
+            "-o",
+            f"LogLevel={self.log_level}",
+            "-o",
+            f'RequestTTY={"force" if self.force_tty else "auto"}',
+            f"{self.user}@{self.host_name}",
+        ] + command
+
+    @staticmethod
+    def from_config(config: Mapping[str, Json]) -> "SSHConfig":
+        c = MapValidator(config)
+        ssh_binary_path = c.get(str, "ssh_binary_path", None)
+        if ssh_binary_path is None:
+            found_path = shutil.which("ssh")
+            if not isinstance(found_path, str):
+                raise ValueError("Failed to find ssh in $PATH")
+            ssh_binary_path = found_path
+
+        return SSHConfig(
+            user=c.get(str, "user"),
+            host_name=c.get(str, "host"),
+            identity_file=c.get(str, "identity_file"),
+            ssh_binary=ssh_binary_path,
+            config_file=c.get(str, "ssh_config", "/dev/null"),
+            port=c.get(int, "port", 22),
+            connect_timeout=c.get(int, "connect_timeout", 30),
+        )
+
+
+class SSHProvider(Runner):
+    """Device-specific provider for SSH clients."""
+
+    def __init__(self, config: SSHConfig) -> None:
+        """
+        Args:
+            config: SSH client config
+        """
+        logger_tag = f"ssh | {config.host_name}"
+        if config.port != DEFAULT_SSH_PORT:
+            logger_tag += f":{config.port}"
+
+        # Escape IPv6 interface identifier if present.
+        logger_tag = logger_tag.replace("%", "%%")
+
+        self.log = logger.PrefixLoggerAdapter(
+            logging.getLogger(),
+            {
+                logger.PrefixLoggerAdapter.EXTRA_KEY_LOG_PREFIX: f"[{logger_tag}]",
+            },
+        )
+
+        self.config = config
+
+        try:
+            self.wait_until_reachable()
+            self.log.info("sshd is reachable")
+        except Exception as e:
+            raise TimeoutError("sshd is unreachable") from e
+
+    def wait_until_reachable(self) -> None:
+        """Wait for the device to become reachable via SSH.
+
+        Raises:
+            TimeoutError: connect_timeout has expired without a successful SSH
+                connection to the device
+            CalledProcessTransportError: SSH is available on the device but
+                connect_timeout has expired and SSH fails to run
+            subprocess.TimeoutExpired: when the timeout expires while waiting
+                for a child process
+        """
+        timeout_sec = self.config.connect_timeout
+        timeout = time.time() + timeout_sec
+        wait_for_port(self.config.host_name, self.config.port, timeout_sec=timeout_sec)
+
+        while True:
+            try:
+                self._run(
+                    ["echo"], stdin=None, timeout_sec=timeout_sec, log_output=True
+                )
+                return
+            except CalledProcessTransportError as e:
+                # Repeat if necessary; _run() can exit prematurely by receiving
+                # SSH transport errors. These errors can be caused by sshd not
+                # being fully initialized yet.
+                if time.time() < timeout:
+                    continue
+                else:
+                    raise e
+
+    def wait_until_unreachable(
+        self,
+        interval_sec: int = 1,
+        timeout_sec: int = DEFAULT_SSH_CONNECT_TIMEOUT_SEC,
+    ) -> None:
+        """Wait for the device to become unreachable via SSH.
+
+        Args:
+            interval_sec: Seconds to wait between unreachability attempts
+            timeout_sec: Seconds to wait until raising TimeoutError
+
+        Raises:
+            TimeoutError: when timeout_sec has expired without an unsuccessful
+                SSH connection to the device
+        """
+        timeout = time.time() + timeout_sec
+
+        while True:
+            try:
+                wait_for_port(
+                    self.config.host_name,
+                    self.config.port,
+                    timeout_sec=interval_sec,
+                )
+            except TimeoutError:
+                return
+
+            if time.time() < timeout:
+                raise TimeoutError(
+                    f"Connection to {self.config.host_name} is still reachable "
+                    f"after {timeout_sec}s"
+                )
+
+    def run(
+        self,
+        command: str | list[str],
+        stdin: bytes | None = None,
+        timeout_sec: float | None = DEFAULT_SSH_TIMEOUT_SEC,
+        log_output: bool = True,
+        connect_retries: int = 3,
+    ) -> subprocess.CompletedProcess[bytes]:
+        """Run a command on the device then exit.
+
+        Args:
+            command: String to send to the device.
+            stdin: Standard input to command.
+            timeout_sec: Seconds to wait for the command to complete.
+            connect_retries: Amount of times to retry connect on fail.
+
+        Raises:
+            subprocess.CalledProcessError: when the process exits with a non-zero status
+            subprocess.TimeoutExpired: when the timeout expires while waiting
+                for a child process
+            CalledProcessTransportError: when the underlying transport fails
+
+        Returns:
+            SSHResults from the executed command.
+        """
+        if isinstance(command, str):
+            s = shlex.shlex(command, posix=True, punctuation_chars=True)
+            s.whitespace_split = True
+            command = list(s)
+        return self._run_with_retry(
+            command, stdin, timeout_sec, log_output, connect_retries
+        )
+
+    def _run_with_retry(
+        self,
+        command: list[str],
+        stdin: bytes | None,
+        timeout_sec: float | None,
+        log_output: bool,
+        connect_retries: int,
+    ) -> subprocess.CompletedProcess[bytes]:
+        err: Exception = ValueError("connect_retries cannot be 0")
+        for _ in range(0, connect_retries):
+            try:
+                return self._run(command, stdin, timeout_sec, log_output)
+            except CalledProcessTransportError as e:
+                err = e
+                self.log.warning("Connect failed: %s", e)
+        raise err
+
+    def _run(
+        self,
+        command: list[str],
+        stdin: bytes | None,
+        timeout_sec: float | None,
+        log_output: bool,
+    ) -> subprocess.CompletedProcess[bytes]:
+        start = time.perf_counter()
+        with self.start(command) as process:
+            try:
+                stdout, stderr = process.communicate(stdin, timeout_sec)
+            except subprocess.TimeoutExpired as e:
+                process.kill()
+                process.wait()
+                raise e
+            except:  # Including KeyboardInterrupt, communicate handled that.
+                process.kill()
+                # We don't call process.wait() as Popen.__exit__ does that for
+                # us.
+                raise
+
+            elapsed = time.perf_counter() - start
+            exit_code = process.poll()
+
+            if log_output:
+                self.log.debug(
+                    "Command %s exited with %d after %.2fs\nstdout: %s\nstderr: %s",
+                    " ".join(command),
+                    exit_code,
+                    elapsed,
+                    stdout.decode("utf-8", errors="replace"),
+                    stderr.decode("utf-8", errors="replace"),
+                )
+            else:
+                self.log.debug(
+                    "Command %s exited with %d after %.2fs",
+                    " ".join(command),
+                    exit_code,
+                    elapsed,
+                )
+
+            if exit_code is None:
+                raise ValueError(
+                    f'Expected process to be terminated: "{" ".join(command)}"'
+                )
+
+            if exit_code:
+                err = CalledProcessError(
+                    exit_code, process.args, output=stdout, stderr=stderr
+                )
+
+                if err.returncode == 255:
+                    reason = stderr.decode("utf-8", errors="replace")
+                    if (
+                        "Name or service not known" in reason
+                        or "Host does not exist" in reason
+                    ):
+                        raise CalledProcessTransportError(
+                            f"Hostname {self.config.host_name} cannot be resolved to an address"
+                        ) from err
+                    if "Connection timed out" in reason:
+                        raise CalledProcessTransportError(
+                            f"Failed to establish a connection to {self.config.host_name} within {timeout_sec}s"
+                        ) from err
+                    if "Connection refused" in reason:
+                        raise CalledProcessTransportError(
+                            f"Connection refused by {self.config.host_name}"
+                        ) from err
+
+                raise err
+
+        return subprocess.CompletedProcess(process.args, exit_code, stdout, stderr)
+
+    def run_async(self, command: str) -> subprocess.CompletedProcess[bytes]:
+        s = shlex.shlex(command, posix=True, punctuation_chars=True)
+        s.whitespace_split = True
+        command_split = list(s)
+
+        process = self.start(command_split)
+        return subprocess.CompletedProcess(
+            self.config.full_command(command_split),
+            returncode=0,
+            stdout=str(process.pid).encode("utf-8"),
+            stderr=None,
+        )
+
+    def start(
+        self,
+        command: list[str],
+        stdout: IO[bytes] | int = subprocess.PIPE,
+        stdin: IO[bytes] | int = subprocess.PIPE,
+    ) -> subprocess.Popen[bytes]:
+        full_command = self.config.full_command(command)
+        self.log.debug(
+            f"Starting: {' '.join(command)}\nFull command: {' '.join(full_command)}"
+        )
+        return subprocess.Popen(
+            full_command,
+            stdin=stdin,
+            stdout=stdout if stdout else subprocess.PIPE,
+            stderr=subprocess.PIPE,
+            preexec_fn=os.setpgrp,
+        )
diff --git a/packages/antlion/context.py b/packages/antlion/context.py
new file mode 100644
index 0000000..3f2481f
--- /dev/null
+++ b/packages/antlion/context.py
@@ -0,0 +1,337 @@
+#!/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 enum
+import logging
+import os
+
+from antlion.event import event_bus
+from antlion.event.event import (
+    Event,
+    TestCaseBeginEvent,
+    TestCaseEndEvent,
+    TestClassBeginEvent,
+    TestClassEndEvent,
+    TestClassEvent,
+)
+
+
+class ContextLevel(enum.IntEnum):
+    ROOT = 0
+    TESTCLASS = 1
+    TESTCASE = 2
+
+
+def get_current_context(depth=None):
+    """Get the current test context at the specified depth.
+    Pulls the most recently created context, with a level at or below the given
+    depth, from the _contexts stack.
+
+    Args:
+        depth: The desired context level. For example, the TESTCLASS level would
+            yield the current test class context, even if the test is currently
+            within a test case.
+
+    Returns: An instance of TestContext.
+    """
+    if depth is None:
+        return _contexts[-1]
+    return _contexts[min(depth, len(_contexts) - 1)]
+
+
+def _get_context_for_test_case_event(event):
+    """Generate a TestCaseContext from the given TestCaseEvent."""
+    return TestCaseContext(event.test_class, event.test_case)
+
+
+def _get_context_for_test_class_event(event):
+    """Generate a TestClassContext from the given TestClassEvent."""
+    return TestClassContext(event.test_class)
+
+
+class NewContextEvent(Event):
+    """The event posted when a test context has changed."""
+
+
+class NewTestClassContextEvent(NewContextEvent):
+    """The event posted when the test class context has changed."""
+
+
+class NewTestCaseContextEvent(NewContextEvent):
+    """The event posted when the test case context has changed."""
+
+
+def _update_test_class_context(event):
+    """Pushes a new TestClassContext to the _contexts stack upon a
+    TestClassBeginEvent. Pops the most recent context off the stack upon a
+    TestClassEndEvent. Posts the context change to the event bus.
+
+    Args:
+        event: An instance of TestClassBeginEvent or TestClassEndEvent.
+    """
+    if isinstance(event, TestClassBeginEvent):
+        _contexts.append(_get_context_for_test_class_event(event))
+    if isinstance(event, TestClassEndEvent):
+        if _contexts:
+            _contexts.pop()
+    event_bus.post(NewTestClassContextEvent())
+
+
+def _update_test_case_context(event):
+    """Pushes a new TestCaseContext to the _contexts stack upon a
+    TestCaseBeginEvent. Pops the most recent context off the stack upon a
+    TestCaseEndEvent. Posts the context change to the event bus.
+
+    Args:
+        event: An instance of TestCaseBeginEvent or TestCaseEndEvent.
+    """
+    if isinstance(event, TestCaseBeginEvent):
+        _contexts.append(_get_context_for_test_case_event(event))
+    if isinstance(event, TestCaseEndEvent):
+        if _contexts:
+            _contexts.pop()
+    event_bus.post(NewTestCaseContextEvent())
+
+
+event_bus.register(TestClassEvent, _update_test_class_context)
+event_bus.register(TestCaseBeginEvent, _update_test_case_context, order=-100)
+event_bus.register(TestCaseEndEvent, _update_test_case_context, order=100)
+
+
+class TestContext(object):
+    """An object representing the current context in which a test is executing.
+
+    The context encodes the current state of the test runner with respect to a
+    particular scenario in which code is being executed. For example, if some
+    code is being executed as part of a test case, then the context should
+    encode information about that test case such as its name or enclosing
+    class.
+
+    The subcontext specifies a relative path in which certain outputs,
+    e.g. logcat, should be kept for the given context.
+
+    The full output path is given by
+    <base_output_path>/<context_dir>/<subcontext>.
+
+    Attributes:
+        _base_output_paths: a dictionary mapping a logger's name to its base
+                            output path
+        _subcontexts: a dictionary mapping a logger's name to its
+                      subcontext-level output directory
+    """
+
+    _base_output_paths = {}
+    _subcontexts = {}
+
+    def get_base_output_path(self, log_name=None):
+        """Gets the base output path for this logger.
+
+        The base output path is interpreted as the reporting root for the
+        entire test runner.
+
+        If a path has been added with add_base_output_path, it is returned.
+        Otherwise, a default is determined by _get_default_base_output_path().
+
+        Args:
+            log_name: The name of the logger.
+
+        Returns:
+            The output path.
+        """
+        if log_name in self._base_output_paths:
+            return self._base_output_paths[log_name]
+        return self._get_default_base_output_path()
+
+    @classmethod
+    def add_base_output_path(cls, log_name, base_output_path):
+        """Store the base path for this logger.
+
+        Args:
+            log_name: The name of the logger.
+            base_output_path: The base path of output files for this logger.
+        """
+        cls._base_output_paths[log_name] = base_output_path
+
+    def get_subcontext(self, log_name=None):
+        """Gets the subcontext for this logger.
+
+        The subcontext is interpreted as the directory, relative to the
+        context-level path, where all outputs of the given logger are stored.
+
+        If a path has been added with add_subcontext, it is returned.
+        Otherwise, the empty string is returned.
+
+        Args:
+            log_name: The name of the logger.
+
+        Returns:
+            The output path.
+        """
+        return self._subcontexts.get(log_name, "")
+
+    @classmethod
+    def add_subcontext(cls, log_name, subcontext):
+        """Store the subcontext path for this logger.
+
+        Args:
+            log_name: The name of the logger.
+            subcontext: The relative subcontext path of output files for this
+                        logger.
+        """
+        cls._subcontexts[log_name] = subcontext
+
+    def get_full_output_path(self, log_name=None):
+        """Gets the full output path for this context.
+
+        The full path represents the absolute path to the output directory,
+        as given by <base_output_path>/<context_dir>/<subcontext>
+
+        Args:
+            log_name: The name of the logger. Used to specify the base output
+                      path and the subcontext.
+
+        Returns:
+            The output path.
+        """
+
+        path = os.path.join(
+            self.get_base_output_path(log_name),
+            self._get_default_context_dir(),
+            self.get_subcontext(log_name),
+        )
+        os.makedirs(path, exist_ok=True)
+        return path
+
+    @property
+    def identifier(self):
+        raise NotImplementedError()
+
+    def _get_default_base_output_path(self):
+        """Gets the default base output path.
+
+        This will attempt to use the ACTS logging path set up in the global
+        logger.
+
+        Returns:
+            The logging path.
+
+        Raises:
+            EnvironmentError: If the ACTS logger has not been initialized.
+        """
+        try:
+            return logging.log_path
+        except AttributeError as e:
+            raise EnvironmentError(
+                "The ACTS logger has not been set up and"
+                ' "base_output_path" has not been set.'
+            ) from e
+
+    def _get_default_context_dir(self):
+        """Gets the default output directory for this context."""
+        raise NotImplementedError()
+
+
+class RootContext(TestContext):
+    """A TestContext that represents a test run."""
+
+    @property
+    def identifier(self):
+        return "root"
+
+    def _get_default_context_dir(self):
+        """Gets the default output directory for this context.
+
+        Logs at the root level context are placed directly in the base level
+        directory, so no context-level path exists."""
+        return ""
+
+
+class TestClassContext(TestContext):
+    """A TestContext that represents a test class.
+
+    Attributes:
+        test_class: The test class instance that this context represents.
+    """
+
+    def __init__(self, test_class):
+        """Initializes a TestClassContext for the given test class.
+
+        Args:
+            test_class: A test class object. Must be an instance of the test
+                        class, not the class object itself.
+        """
+        self.test_class = test_class
+
+    @property
+    def test_class_name(self):
+        return self.test_class.__class__.__name__
+
+    @property
+    def identifier(self):
+        return self.test_class_name
+
+    def _get_default_context_dir(self):
+        """Gets the default output directory for this context.
+
+        For TestClassContexts, this will be the name of the test class. This is
+        in line with the ACTS logger itself.
+        """
+        return self.test_class_name
+
+
+class TestCaseContext(TestContext):
+    """A TestContext that represents a test case.
+
+    Attributes:
+        test_case: The string name of the test case.
+        test_class: The test class instance enclosing the test case.
+    """
+
+    def __init__(self, test_class, test_case):
+        """Initializes a TestCaseContext for the given test case.
+
+        Args:
+            test_class: A test class object. Must be an instance of the test
+                        class, not the class object itself.
+            test_case: The string name of the test case.
+        """
+        self.test_class = test_class
+        self.test_case = test_case
+
+    @property
+    def test_case_name(self):
+        return self.test_case
+
+    @property
+    def test_class_name(self):
+        return self.test_class.__class__.__name__
+
+    @property
+    def identifier(self):
+        return f"{self.test_class_name}.{self.test_case_name}"
+
+    def _get_default_context_dir(self):
+        """Gets the default output directory for this context.
+
+        For TestCaseContexts, this will be the name of the test class followed
+        by the name of the test case. This is in line with the ACTS logger
+        itself.
+        """
+        return os.path.join(self.test_class_name, self.test_case_name)
+
+
+# stack for keeping track of the current test context
+_contexts = [RootContext()]
diff --git a/src/antlion/controllers/OWNERS b/packages/antlion/controllers/OWNERS
similarity index 100%
rename from src/antlion/controllers/OWNERS
rename to packages/antlion/controllers/OWNERS
diff --git a/packages/antlion/controllers/__init__.py b/packages/antlion/controllers/__init__.py
new file mode 100644
index 0000000..6d1ae5a
--- /dev/null
+++ b/packages/antlion/controllers/__init__.py
@@ -0,0 +1,47 @@
+#!/usr/bin/env python3
+#
+# Copyright 2024 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.
+
+from . import (
+    access_point,
+    adb,
+    android_device,
+    attenuator,
+    fastboot,
+    fuchsia_device,
+    iperf_client,
+    iperf_server,
+    openwrt_ap,
+    packet_capture,
+    pdu,
+    sniffer,
+)
+
+# Reexport so static type checkers can find these modules when importing and
+# using antlion.controllers instead of "from antlion.controller import ..."
+__all__ = [
+    "access_point",
+    "adb",
+    "android_device",
+    "attenuator",
+    "fastboot",
+    "fuchsia_device",
+    "iperf_client",
+    "iperf_server",
+    "openwrt_ap",
+    "packet_capture",
+    "pdu",
+    "sniffer",
+]
diff --git a/packages/antlion/controllers/access_point.py b/packages/antlion/controllers/access_point.py
new file mode 100755
index 0000000..15e65b3
--- /dev/null
+++ b/packages/antlion/controllers/access_point.py
@@ -0,0 +1,925 @@
+#!/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.
+
+from __future__ import annotations
+
+import ipaddress
+import logging
+import os
+import time
+from dataclasses import dataclass
+from typing import Any, FrozenSet
+
+from mobly import logger
+
+from antlion import utils
+from antlion.capabilities.ssh import SSHConfig, SSHProvider
+from antlion.controllers.ap_lib import hostapd_constants
+from antlion.controllers.ap_lib.ap_get_interface import ApInterfaces
+from antlion.controllers.ap_lib.ap_iwconfig import ApIwconfig
+from antlion.controllers.ap_lib.bridge_interface import BridgeInterface
+from antlion.controllers.ap_lib.dhcp_config import DhcpConfig, Subnet
+from antlion.controllers.ap_lib.dhcp_server import DhcpServer, NoInterfaceError
+from antlion.controllers.ap_lib.extended_capabilities import ExtendedCapabilities
+from antlion.controllers.ap_lib.hostapd import Hostapd
+from antlion.controllers.ap_lib.hostapd_ap_preset import create_ap_preset
+from antlion.controllers.ap_lib.hostapd_config import HostapdConfig
+from antlion.controllers.ap_lib.hostapd_security import Security
+from antlion.controllers.ap_lib.radvd import Radvd
+from antlion.controllers.ap_lib.radvd_config import RadvdConfig
+from antlion.controllers.ap_lib.wireless_network_management import (
+    BssTransitionManagementRequest,
+)
+from antlion.controllers.pdu import PduDevice, get_pdu_port_for_device
+from antlion.controllers.utils_lib.commands import command, ip, journalctl, route
+from antlion.controllers.utils_lib.commands.date import LinuxDateCommand
+from antlion.controllers.utils_lib.commands.tcpdump import LinuxTcpdumpCommand
+from antlion.controllers.utils_lib.ssh import connection, settings
+from antlion.runner import CalledProcessError
+from antlion.types import ControllerConfig, Json
+from antlion.validation import MapValidator
+
+MOBLY_CONTROLLER_CONFIG_NAME: str = "AccessPoint"
+ACTS_CONTROLLER_REFERENCE_NAME = "access_points"
+
+
+class Error(Exception):
+    """Error raised when there is a problem with the access point."""
+
+
+@dataclass
+class _ApInstance:
+    hostapd: Hostapd
+    subnet: Subnet
+
+
+# These ranges were split this way since each physical radio can have up
+# to 8 SSIDs so for the 2GHz radio the DHCP range will be
+# 192.168.1 - 8 and the 5Ghz radio will be 192.168.9 - 16
+_AP_2GHZ_SUBNET_STR_DEFAULT = "192.168.1.0/24"
+_AP_5GHZ_SUBNET_STR_DEFAULT = "192.168.9.0/24"
+
+# The last digit of the ip for the bridge interface
+BRIDGE_IP_LAST = "100"
+
+
+def create(configs: list[ControllerConfig]) -> list[AccessPoint]:
+    """Creates ap controllers from a json config.
+
+    Creates an ap controller from either a list, or a single
+    element. The element can either be just the hostname or a dictionary
+    containing the hostname and username of the ap to connect to over ssh.
+
+    Args:
+        The json configs that represent this controller.
+
+    Returns:
+        A new AccessPoint.
+    """
+    return [AccessPoint(c) for c in configs]
+
+
+def destroy(objects: list[AccessPoint]) -> None:
+    """Destroys a list of access points.
+
+    Args:
+        aps: The list of access points to destroy.
+    """
+    for ap in objects:
+        ap.close()
+
+
+def get_info(objects: list[AccessPoint]) -> list[Json]:
+    """Get information on a list of access points.
+
+    Args:
+        aps: A list of AccessPoints.
+
+    Returns:
+        A list of all aps hostname.
+    """
+    return [ap.ssh_settings.hostname for ap in objects]
+
+
+class AccessPoint:
+    """An access point controller.
+
+    Attributes:
+        ssh: The ssh connection to this ap.
+        ssh_settings: The ssh settings being used by the ssh connection.
+        dhcp_settings: The dhcp server settings being used.
+    """
+
+    def __init__(self, config: ControllerConfig) -> None:
+        """
+        Args:
+            configs: configs for the access point from config file.
+        """
+        c = MapValidator(config)
+        self.ssh_settings = settings.from_config(c.get(dict, "ssh_config"))
+        self.log = logger.PrefixLoggerAdapter(
+            logging.getLogger(),
+            {
+                logger.PrefixLoggerAdapter.EXTRA_KEY_LOG_PREFIX: f"[Access Point|{self.ssh_settings.hostname}]",
+            },
+        )
+        self.device_pdu_config = c.get(dict, "PduDevice", None)
+        self.identifier = self.ssh_settings.hostname
+
+        subnet = MapValidator(c.get(dict, "ap_subnet", {}))
+        self._AP_2G_SUBNET_STR = subnet.get(str, "2g", _AP_2GHZ_SUBNET_STR_DEFAULT)
+        self._AP_5G_SUBNET_STR = subnet.get(str, "5g", _AP_5GHZ_SUBNET_STR_DEFAULT)
+
+        self._AP_2G_SUBNET = Subnet(ipaddress.IPv4Network(self._AP_2G_SUBNET_STR))
+        self._AP_5G_SUBNET = Subnet(ipaddress.IPv4Network(self._AP_5G_SUBNET_STR))
+
+        self.ssh = connection.SshConnection(self.ssh_settings)
+
+        # TODO(http://b/278758876): Replace self.ssh with self.ssh_provider
+        self.ssh_provider = SSHProvider(
+            SSHConfig(
+                self.ssh_settings.username,
+                self.ssh_settings.hostname,
+                self.ssh_settings.identity_file,
+                port=self.ssh_settings.port,
+                ssh_binary=self.ssh_settings.executable,
+                connect_timeout=90,
+            )
+        )
+
+        # Singleton utilities for running various commands.
+        self._ip_cmd = command.require(ip.LinuxIpCommand(self.ssh))
+        self._route_cmd = command.require(route.LinuxRouteCommand(self.ssh))
+        self._journalctl_cmd = command.require(
+            journalctl.LinuxJournalctlCommand(self.ssh)
+        )
+
+        # A map from network interface name to _ApInstance objects representing
+        # the hostapd instance running against the interface.
+        self._aps: dict[str, _ApInstance] = dict()
+        self._dhcp: DhcpServer | None = None
+        self._dhcp_bss: dict[str, Subnet] = dict()
+        self._radvd: Radvd | None = None
+        self.bridge = BridgeInterface(self)
+        self.iwconfig = ApIwconfig(self)
+
+        # Check to see if wan_interface is specified in acts_config for tests
+        # isolated from the internet and set this override.
+        self.interfaces = ApInterfaces(self, c.get(str, "wan_interface", None))
+
+        # Get needed interface names and initialize the unnecessary ones.
+        self.wan = self.interfaces.get_wan_interface()
+        self.wlan = self.interfaces.get_wlan_interface()
+        self.wlan_2g = self.wlan[0]
+        self.wlan_5g = self.wlan[1]
+        self.lan = self.interfaces.get_lan_interface()
+        self._initial_ap()
+        self.setup_bridge = False
+
+        # Allow use of tcpdump
+        self.tcpdump = LinuxTcpdumpCommand(self.ssh_provider)
+
+        # Access points are not given internet access, so their system time needs to be
+        # manually set to be accurate.
+        LinuxDateCommand(self.ssh_provider).sync()
+
+    def _initial_ap(self) -> None:
+        """Initial AP interfaces.
+
+        Bring down hostapd if instance is running, bring down all bridge
+        interfaces.
+        """
+        # This is necessary for Gale/Whirlwind flashed with dev channel image
+        # Unused interfaces such as existing hostapd daemon, guest, mesh
+        # interfaces need to be brought down as part of the AP initialization
+        # process, otherwise test would fail.
+        try:
+            self.ssh.run("stop wpasupplicant")
+        except CalledProcessError:
+            self.log.info("No wpasupplicant running")
+        try:
+            self.ssh.run("stop hostapd")
+        except CalledProcessError:
+            self.log.info("No hostapd running")
+        # Bring down all wireless interfaces
+        for iface in self.wlan:
+            WLAN_DOWN = f"ip link set {iface} down"
+            self.ssh.run(WLAN_DOWN)
+        # Bring down all bridge interfaces
+        bridge_interfaces = self.interfaces.get_bridge_interface()
+        for iface in bridge_interfaces:
+            BRIDGE_DOWN = f"ip link set {iface} down"
+            BRIDGE_DEL = f"brctl delbr {iface}"
+            self.ssh.run(BRIDGE_DOWN)
+            self.ssh.run(BRIDGE_DEL)
+
+    def start_ap(
+        self,
+        hostapd_config: HostapdConfig,
+        radvd_config: RadvdConfig | None = None,
+        setup_bridge: bool = False,
+        is_nat_enabled: bool = True,
+        additional_parameters: dict[str, Any] | None = None,
+    ) -> list[str]:
+        """Starts as an ap using a set of configurations.
+
+        This will start an ap on this host. To start an ap the controller
+        selects a network interface to use based on the configs given. It then
+        will start up hostapd on that interface. Next a subnet is created for
+        the network interface and dhcp server is refreshed to give out ips
+        for that subnet for any device that connects through that interface.
+
+        Args:
+            hostapd_config: The configurations to use when starting up the ap.
+            radvd_config: The IPv6 configuration to use when starting up the ap.
+            setup_bridge: Whether to bridge the LAN interface WLAN interface.
+                Only one WLAN interface can be bridged with the LAN interface
+                and none of the guest networks can be bridged.
+            is_nat_enabled: If True, start NAT on the AP to allow the DUT to be
+                able to access the internet if the WAN port is connected to the
+                internet.
+            additional_parameters: Parameters that can sent directly into the
+                hostapd config file.  This can be used for debugging and or
+                adding one off parameters into the config.
+
+        Returns:
+            An identifier for each ssid being started. These identifiers can be
+            used later by this controller to control the ap.
+
+        Raises:
+            Error: When the ap can't be brought up.
+        """
+        if additional_parameters is None:
+            additional_parameters = {}
+
+        if hostapd_config.frequency < 5000:
+            interface = self.wlan_2g
+            subnet = self._AP_2G_SUBNET
+        else:
+            interface = self.wlan_5g
+            subnet = self._AP_5G_SUBNET
+
+        # radvd requires the interface to have a IPv6 link-local address.
+        if radvd_config:
+            self.ssh.run(f"sysctl -w net.ipv6.conf.{interface}.disable_ipv6=0")
+            self.ssh.run(f"sysctl -w net.ipv6.conf.{interface}.forwarding=1")
+
+        # In order to handle dhcp servers on any interface, the initiation of
+        # the dhcp server must be done after the wlan interfaces are figured
+        # out as opposed to being in __init__
+        self._dhcp = DhcpServer(self.ssh, interface=interface)
+
+        # For multi bssid configurations the mac address
+        # of the wireless interface needs to have enough space to mask out
+        # up to 8 different mac addresses. So in for one interface the range is
+        # hex 0-7 and for the other the range is hex 8-f.
+        ip = self.ssh.run(["ip", "link", "show", interface])
+
+        # Example output:
+        # 5: wlan0: <BROADCAST,MULTICAST> mtu 1500 qdisc mq state DOWN mode DEFAULT group default qlen 1000
+        #     link/ether f4:f2:6d:aa:99:28 brd ff:ff:ff:ff:ff:ff
+
+        lines = ip.stdout.decode("utf-8").splitlines()
+        if len(lines) != 2:
+            raise RuntimeError(f"Expected 2 lines from ip link show, got {len(lines)}")
+        tokens = lines[1].split()
+        if len(tokens) != 4:
+            raise RuntimeError(
+                f"Expected 4 tokens from ip link show, got {len(tokens)}"
+            )
+        interface_mac_orig = tokens[1]
+
+        if interface == self.wlan_5g:
+            hostapd_config.bssid = f"{interface_mac_orig[:-1]}0"
+            last_octet = 1
+        elif interface == self.wlan_2g:
+            hostapd_config.bssid = f"{interface_mac_orig[:-1]}8"
+            last_octet = 9
+        elif interface in self._aps:
+            raise ValueError(
+                "No WiFi interface available for AP on "
+                f"channel {hostapd_config.channel}"
+            )
+        else:
+            raise ValueError(f"Invalid WLAN interface: {interface}")
+
+        apd = Hostapd(self.ssh, interface)
+        new_instance = _ApInstance(hostapd=apd, subnet=subnet)
+        self._aps[interface] = new_instance
+
+        # Turn off the DHCP server, we're going to change its settings.
+        self.stop_dhcp()
+        # Clear all routes to prevent old routes from interfering.
+        self._route_cmd.clear_routes(net_interface=interface)
+        # Add IPv6 link-local route so packets destined to the AP will be
+        # processed by the AP. This is necessary if an iperf server is running
+        # on the AP, but not for traffic handled by the Linux networking stack
+        # such as ping.
+        if radvd_config:
+            self._route_cmd.add_route(interface, ipaddress.IPv6Interface("fe80::/64"))
+
+        self._dhcp_bss = dict()
+        if hostapd_config.bss_lookup:
+            # The self._dhcp_bss dictionary is created to hold the key/value
+            # pair of the interface name and the ip scope that will be
+            # used for the particular interface.  The a, b, c, d
+            # variables below are the octets for the ip address.  The
+            # third octet is then incremented for each interface that
+            # is requested.  This part is designed to bring up the
+            # hostapd interfaces and not the DHCP servers for each
+            # interface.
+            counter = 1
+            for iface in hostapd_config.bss_lookup:
+                hostapd_config.bss_lookup[iface].bssid = (
+                    interface_mac_orig[:-1] + hex(last_octet)[-1:]
+                )
+                self._route_cmd.clear_routes(net_interface=str(iface))
+                if interface is self.wlan_2g:
+                    starting_ip_range = self._AP_2G_SUBNET_STR
+                else:
+                    starting_ip_range = self._AP_5G_SUBNET_STR
+                a, b, c, d = starting_ip_range.split(".")
+                self._dhcp_bss[iface] = Subnet(
+                    ipaddress.IPv4Network(f"{a}.{b}.{int(c) + counter}.{d}")
+                )
+                counter = counter + 1
+                last_octet = last_octet + 1
+
+        apd.start(hostapd_config, additional_parameters=additional_parameters)
+
+        # The DHCP serer requires interfaces to have ips and routes before
+        # the server will come up.
+        interface_ip = ipaddress.IPv4Interface(
+            f"{subnet.router}/{subnet.network.prefixlen}"
+        )
+        if setup_bridge is True:
+            bridge_interface_name = "eth_test"
+            interfaces = [interface]
+            if self.lan:
+                interfaces.append(self.lan)
+            self.create_bridge(bridge_interface_name, interfaces)
+            self._ip_cmd.set_ipv4_address(bridge_interface_name, interface_ip)
+        else:
+            self._ip_cmd.set_ipv4_address(interface, interface_ip)
+        if hostapd_config.bss_lookup:
+            # This loop goes through each interface that was setup for
+            # hostapd and assigns the DHCP scopes that were defined but
+            # not used during the hostapd loop above.  The k and v
+            # variables represent the interface name, k, and dhcp info, v.
+            for iface, subnet in self._dhcp_bss.items():
+                bss_interface_ip = ipaddress.IPv4Interface(
+                    f"{subnet.router}/{subnet.network.prefixlen}"
+                )
+                self._ip_cmd.set_ipv4_address(iface, bss_interface_ip)
+
+        # Restart the DHCP server with our updated list of subnets.
+        configured_subnets = self.get_configured_subnets()
+        dhcp_conf = DhcpConfig(subnets=configured_subnets)
+        self.start_dhcp(dhcp_conf=dhcp_conf)
+        if is_nat_enabled:
+            self.start_nat()
+            self.enable_forwarding()
+        else:
+            self.stop_nat()
+            self.enable_forwarding()
+        if radvd_config:
+            radvd_interface = bridge_interface_name if setup_bridge else interface
+            self._radvd = Radvd(self.ssh, radvd_interface)
+            self._radvd.start(radvd_config)
+        else:
+            self._radvd = None
+
+        bss_interfaces = [bss for bss in hostapd_config.bss_lookup]
+        bss_interfaces.append(interface)
+
+        return bss_interfaces
+
+    def get_configured_subnets(self) -> list[Subnet]:
+        """Get the list of configured subnets on the access point.
+
+        This allows consumers of the access point objects create custom DHCP
+        configs with the correct subnets.
+
+        Returns: a list of Subnet objects
+        """
+        configured_subnets = [x.subnet for x in self._aps.values()]
+        for k, v in self._dhcp_bss.items():
+            configured_subnets.append(v)
+        return configured_subnets
+
+    def start_dhcp(self, dhcp_conf: DhcpConfig) -> None:
+        """Start a DHCP server for the specified subnets.
+
+        This allows consumers of the access point objects to control DHCP.
+
+        Args:
+            dhcp_conf: A DhcpConfig object.
+
+        Raises:
+            Error: Raised when a dhcp server error is found.
+        """
+        if self._dhcp is not None:
+            self._dhcp.start(config=dhcp_conf)
+
+    def stop_dhcp(self) -> None:
+        """Stop DHCP for this AP object.
+
+        This allows consumers of the access point objects to control DHCP.
+        """
+        if self._dhcp is not None:
+            self._dhcp.stop()
+
+    def get_systemd_journal(self) -> str:
+        """Get systemd journal logs from this current boot."""
+        return self._journalctl_cmd.logs()
+
+    def get_dhcp_logs(self) -> str | None:
+        """Get DHCP logs for this AP object.
+
+        This allows consumers of the access point objects to validate DHCP
+        behavior.
+
+        Returns:
+            A string of the dhcp server logs, or None is a DHCP server has not
+            been started.
+        """
+        if self._dhcp is not None:
+            return self._dhcp.get_logs()
+        return None
+
+    def get_hostapd_logs(self) -> dict[str, str]:
+        """Get hostapd logs for all interfaces on AP object.
+
+        This allows consumers of the access point objects to validate hostapd
+        behavior.
+
+        Returns: A dict with {interface: log} from hostapd instances.
+        """
+        hostapd_logs: dict[str, str] = dict()
+        for iface, ap in self._aps.items():
+            hostapd_logs[iface] = ap.hostapd.pull_logs()
+        return hostapd_logs
+
+    def get_radvd_logs(self) -> str | None:
+        """Get radvd logs for this AP object.
+
+        This allows consumers of the access point objects to validate radvd
+        behavior.
+
+        Returns:
+            A string of the radvd logs, or None is a radvd server has not been
+            started.
+        """
+        if self._radvd:
+            return self._radvd.pull_logs()
+        return None
+
+    def download_ap_logs(self, path: str) -> None:
+        """Download all available logs from the AP.
+
+        Args:
+            path: Path to write logs to.
+
+        This convenience method gets all the logs, dhcp, hostapd, radvd. It
+        writes these to the given path.
+        """
+        dhcp_log = self.get_dhcp_logs()
+        if dhcp_log:
+            dhcp_log_path = os.path.join(path, f"{self.identifier}_dhcp_log.txt")
+            with open(dhcp_log_path, "a") as f:
+                f.write(dhcp_log)
+
+        hostapd_logs = self.get_hostapd_logs()
+        for interface in hostapd_logs:
+            hostapd_log_path = os.path.join(
+                path,
+                f"{self.identifier}_hostapd_log_{interface}.txt",
+            )
+            with open(hostapd_log_path, "a") as f:
+                f.write(hostapd_logs[interface])
+
+        radvd_log = self.get_radvd_logs()
+        if radvd_log:
+            radvd_log_path = os.path.join(path, f"{self.identifier}_radvd_log.txt")
+            with open(radvd_log_path, "a") as f:
+                f.write(radvd_log)
+
+        systemd_journal = self.get_systemd_journal()
+        systemd_journal_path = os.path.join(
+            path, f"{self.identifier}_systemd_journal.txt"
+        )
+        with open(systemd_journal_path, "a") as f:
+            f.write(systemd_journal)
+
+    def enable_forwarding(self) -> None:
+        """Enable IPv4 and IPv6 forwarding on the AP.
+
+        When forwarding is enabled, the access point is able to route IP packets
+        between devices in the same subnet.
+        """
+        self.ssh.run("echo 1 > /proc/sys/net/ipv4/ip_forward")
+        self.ssh.run("echo 1 > /proc/sys/net/ipv6/conf/all/forwarding")
+
+    def start_nat(self) -> None:
+        """Start NAT on the AP.
+
+        This allows consumers of the access point objects to enable NAT
+        on the AP.
+
+        Note that this is currently a global setting, since we don't
+        have per-interface masquerade rules.
+        """
+        # The following three commands are needed to enable NAT between
+        # the WAN and LAN/WLAN ports.  This means anyone connecting to the
+        # WLAN/LAN ports will be able to access the internet if the WAN port
+        # is connected to the internet.
+        self.ssh.run("iptables -t nat -F")
+        self.ssh.run(f"iptables -t nat -A POSTROUTING -o {self.wan} -j MASQUERADE")
+
+    def stop_nat(self) -> None:
+        """Stop NAT on the AP.
+
+        This allows consumers of the access point objects to disable NAT on the
+        AP.
+
+        Note that this is currently a global setting, since we don't have
+        per-interface masquerade rules.
+        """
+        self.ssh.run("iptables -t nat -F")
+
+    def create_bridge(self, bridge_name: str, interfaces: list[str]) -> None:
+        """Create the specified bridge and bridge the specified interfaces.
+
+        Args:
+            bridge_name: The name of the bridge to create.
+            interfaces: A list of interfaces to add to the bridge.
+        """
+
+        # Create the bridge interface
+        self.ssh.run(f"brctl addbr {bridge_name}")
+
+        for interface in interfaces:
+            self.ssh.run(f"brctl addif {bridge_name} {interface}")
+
+        self.ssh.run(f"ip link set {bridge_name} up")
+
+    def remove_bridge(self, bridge_name: str) -> None:
+        """Removes the specified bridge
+
+        Args:
+            bridge_name: The name of the bridge to remove.
+        """
+        # Check if the bridge exists.
+        #
+        # Cases where it may not are if we failed to initialize properly
+        #
+        # Or if we're doing 2.4Ghz and 5Ghz SSIDs and we've already torn
+        # down the bridge once, but we got called for each band.
+        result = self.ssh.run(f"brctl show {bridge_name}", ignore_status=True)
+
+        # If the bridge exists, we'll get an exit_status of 0, indicating
+        # success, so we can continue and remove the bridge.
+        if result.returncode == 0:
+            self.ssh.run(f"ip link set {bridge_name} down")
+            self.ssh.run(f"brctl delbr {bridge_name}")
+
+    def get_bssid_from_ssid(self, ssid: str, band: str) -> str | None:
+        """Gets the BSSID from a provided SSID
+
+        Args:
+            ssid: An SSID string.
+            band: 2G or 5G Wifi band.
+        Returns: The BSSID if on the AP or None if SSID could not be found.
+        """
+        if band == hostapd_constants.BAND_2G:
+            interfaces = [self.wlan_2g, ssid]
+        else:
+            interfaces = [self.wlan_5g, ssid]
+
+        # Get the interface name associated with the given ssid.
+        for interface in interfaces:
+            iw = self.ssh.run(["iw", "dev", interface, "info"])
+            if b"command failed: No such device" in iw.stderr:
+                continue
+
+            iw_lines = iw.stdout.decode("utf-8").splitlines()
+
+            for line in iw_lines:
+                if "ssid" in line and ssid in line:
+                    # We found the right interface.
+                    for line in iw_lines:
+                        if "addr" in line:
+                            tokens = line.split()
+                            if len(tokens) != 2:
+                                raise RuntimeError(
+                                    f"Expected iw dev info addr to have 2 tokens, got {tokens}"
+                                )
+                            return tokens[2]
+
+                    iw_out = "\n".join(iw_lines)
+                    raise RuntimeError(
+                        f"iw dev info contained ssid but not addr: \n{iw_out}"
+                    )
+
+        return None
+
+    def stop_ap(self, identifier: str) -> None:
+        """Stops a running ap on this controller.
+
+        Args:
+            identifier: The identify of the ap that should be taken down.
+        """
+
+        instance = self._aps.get(identifier)
+        if instance is None:
+            raise ValueError(f"Invalid identifier {identifier} given")
+
+        if self._radvd:
+            self._radvd.stop()
+        try:
+            self.stop_dhcp()
+        except NoInterfaceError:
+            pass
+        self.stop_nat()
+        instance.hostapd.stop()
+        self._ip_cmd.clear_ipv4_addresses(identifier)
+
+        del self._aps[identifier]
+        bridge_interfaces = self.interfaces.get_bridge_interface()
+        for iface in bridge_interfaces:
+            BRIDGE_DOWN = f"ip link set {iface} down"
+            BRIDGE_DEL = f"brctl delbr {iface}"
+            self.ssh.run(BRIDGE_DOWN)
+            self.ssh.run(BRIDGE_DEL)
+
+    def stop_all_aps(self) -> None:
+        """Stops all running aps on this device."""
+
+        for ap in list(self._aps.keys()):
+            self.stop_ap(ap)
+
+    def close(self) -> None:
+        """Called to take down the entire access point.
+
+        When called will stop all aps running on this host, shutdown the dhcp
+        server, and stop the ssh connection.
+        """
+
+        if self._aps:
+            self.stop_all_aps()
+        self.ssh.close()
+
+    def generate_bridge_configs(self, channel: int) -> tuple[str, str | None, str]:
+        """Generate a list of configs for a bridge between LAN and WLAN.
+
+        Args:
+            channel: the channel WLAN interface is brought up on
+            iface_lan: the LAN interface to bridge
+        Returns:
+            configs: tuple containing iface_wlan, iface_lan and bridge_ip
+        """
+
+        if channel < 15:
+            iface_wlan = self.wlan_2g
+            subnet_str = self._AP_2G_SUBNET_STR
+        else:
+            iface_wlan = self.wlan_5g
+            subnet_str = self._AP_5G_SUBNET_STR
+
+        iface_lan = self.lan
+
+        a, b, c, _ = subnet_str.strip("/24").split(".")
+        bridge_ip = f"{a}.{b}.{c}.{BRIDGE_IP_LAST}"
+
+        return (iface_wlan, iface_lan, bridge_ip)
+
+    def ping(
+        self,
+        dest_ip: str,
+        count: int = 3,
+        interval: int = 1000,
+        timeout: int = 1000,
+        size: int = 56,
+        additional_ping_params: str = "",
+    ) -> utils.PingResult:
+        """Pings from AP to dest_ip, returns dict of ping stats (see utils.ping)"""
+        return utils.ping(
+            self.ssh,
+            dest_ip,
+            count=count,
+            interval=interval,
+            timeout=timeout,
+            size=size,
+            additional_ping_params=additional_ping_params,
+        )
+
+    def hard_power_cycle(
+        self,
+        pdus: list[PduDevice],
+    ) -> None:
+        """Kills, then restores power to AccessPoint, verifying it goes down and
+        comes back online cleanly.
+
+        Args:
+            pdus: PDUs in the testbed
+        Raise:
+            Error, if no PduDevice is provided in AccessPoint config.
+            ConnectionError, if AccessPoint fails to go offline or come back.
+        """
+        if not self.device_pdu_config:
+            raise Error("No PduDevice provided in AccessPoint config.")
+
+        self._journalctl_cmd.save_and_reset()
+
+        self.log.info("Power cycling")
+        ap_pdu, ap_pdu_port = get_pdu_port_for_device(self.device_pdu_config, pdus)
+
+        self.log.info("Killing power")
+        ap_pdu.off(ap_pdu_port)
+
+        self.log.info("Verifying AccessPoint is unreachable.")
+        self.ssh_provider.wait_until_unreachable()
+        self.log.info("AccessPoint is unreachable as expected.")
+
+        self._aps.clear()
+
+        self.log.info("Restoring power")
+        ap_pdu.on(ap_pdu_port)
+
+        self.log.info("Waiting for AccessPoint to become available via SSH.")
+        self.ssh_provider.wait_until_reachable()
+        self.log.info("AccessPoint responded to SSH.")
+
+        # Allow 5 seconds for OS to finish getting set up
+        time.sleep(5)
+        self._initial_ap()
+        self.log.info("Power cycled successfully")
+
+    def channel_switch(self, identifier: str, channel_num: int) -> None:
+        """Switch to a different channel on the given AP."""
+        instance = self._aps.get(identifier)
+        if instance is None:
+            raise ValueError(f"Invalid identifier {identifier} given")
+        self.log.info(f"channel switch to channel {channel_num}")
+        instance.hostapd.channel_switch(channel_num)
+
+    def get_current_channel(self, identifier: str) -> int:
+        """Find the current channel on the given AP."""
+        instance = self._aps.get(identifier)
+        if instance is None:
+            raise ValueError(f"Invalid identifier {identifier} given")
+        return instance.hostapd.get_current_channel()
+
+    def get_stas(self, identifier: str) -> set[str]:
+        """Return MAC addresses of all associated STAs on the given AP."""
+        instance = self._aps.get(identifier)
+        if instance is None:
+            raise ValueError(f"Invalid identifier {identifier} given")
+        return instance.hostapd.get_stas()
+
+    def sta_authenticated(self, identifier: str, sta_mac: str) -> bool:
+        """Is STA authenticated?"""
+        instance = self._aps.get(identifier)
+        if instance is None:
+            raise ValueError(f"Invalid identifier {identifier} given")
+        return instance.hostapd.sta_authenticated(sta_mac)
+
+    def sta_associated(self, identifier: str, sta_mac: str) -> bool:
+        """Is STA associated?"""
+        instance = self._aps.get(identifier)
+        if instance is None:
+            raise ValueError(f"Invalid identifier {identifier} given")
+        return instance.hostapd.sta_associated(sta_mac)
+
+    def sta_authorized(self, identifier: str, sta_mac: str) -> bool:
+        """Is STA authorized (802.1X controlled port open)?"""
+        instance = self._aps.get(identifier)
+        if instance is None:
+            raise ValueError(f"Invalid identifier {identifier} given")
+        return instance.hostapd.sta_authorized(sta_mac)
+
+    def get_sta_extended_capabilities(
+        self, identifier: str, sta_mac: str
+    ) -> ExtendedCapabilities:
+        """Get extended capabilities for the given STA, as seen by the AP."""
+        instance = self._aps.get(identifier)
+        if instance is None:
+            raise ValueError(f"Invalid identifier {identifier} given")
+        return instance.hostapd.get_sta_extended_capabilities(sta_mac)
+
+    def send_bss_transition_management_req(
+        self,
+        identifier: str,
+        sta_mac: str,
+        request: BssTransitionManagementRequest,
+    ) -> None:
+        """Send a BSS Transition Management request to an associated STA."""
+        instance = self._aps.get(identifier)
+        if instance is None:
+            raise ValueError(f"Invalid identifier {identifier} given")
+        instance.hostapd.send_bss_transition_management_req(sta_mac, request)
+
+
+def setup_ap(
+    access_point: AccessPoint,
+    profile_name: str,
+    channel: int,
+    ssid: str,
+    mode: str | None = None,
+    preamble: bool | None = None,
+    beacon_interval: int | None = None,
+    dtim_period: int | None = None,
+    frag_threshold: int | None = None,
+    rts_threshold: int | None = None,
+    force_wmm: bool | None = None,
+    hidden: bool | None = False,
+    security: Security | None = None,
+    pmf_support: int | None = None,
+    additional_ap_parameters: dict[str, Any] | None = None,
+    n_capabilities: list[Any] | None = None,
+    ac_capabilities: list[Any] | None = None,
+    vht_bandwidth: int | None = None,
+    wnm_features: FrozenSet[hostapd_constants.WnmFeature] = frozenset(),
+    setup_bridge: bool = False,
+    is_ipv6_enabled: bool = False,
+    is_nat_enabled: bool = True,
+) -> list[str]:
+    """Creates a hostapd profile and runs it on an ap. This is a convenience
+    function that allows us to start an ap with a single function, without first
+    creating a hostapd config.
+
+    Args:
+        access_point: An ACTS access_point controller
+        profile_name: The profile name of one of the hostapd ap presets.
+        channel: What channel to set the AP to.
+        preamble: Whether to set short or long preamble
+        beacon_interval: The beacon interval
+        dtim_period: Length of dtim period
+        frag_threshold: Fragmentation threshold
+        rts_threshold: RTS threshold
+        force_wmm: Enable WMM or not
+        hidden: Advertise the SSID or not
+        security: What security to enable.
+        pmf_support: Whether pmf is not disabled, enabled, or required
+        additional_ap_parameters: Additional parameters to send the AP.
+        check_connectivity: Whether to check for internet connectivity.
+        wnm_features: WNM features to enable on the AP.
+        setup_bridge: Whether to bridge the LAN interface WLAN interface.
+            Only one WLAN interface can be bridged with the LAN interface
+            and none of the guest networks can be bridged.
+        is_ipv6_enabled: If True, start a IPv6 router advertisement daemon
+        is_nat_enabled: If True, start NAT on the AP to allow the DUT to be able
+            to access the internet if the WAN port is connected to the internet.
+
+    Returns:
+        An identifier for each ssid being started. These identifiers can be
+        used later by this controller to control the ap.
+
+    Raises:
+        Error: When the ap can't be brought up.
+    """
+    if additional_ap_parameters is None:
+        additional_ap_parameters = {}
+
+    ap = create_ap_preset(
+        profile_name=profile_name,
+        iface_wlan_2g=access_point.wlan_2g,
+        iface_wlan_5g=access_point.wlan_5g,
+        channel=channel,
+        ssid=ssid,
+        mode=mode,
+        short_preamble=preamble,
+        beacon_interval=beacon_interval,
+        dtim_period=dtim_period,
+        frag_threshold=frag_threshold,
+        rts_threshold=rts_threshold,
+        force_wmm=force_wmm,
+        hidden=hidden,
+        bss_settings=[],
+        security=security,
+        pmf_support=pmf_support,
+        n_capabilities=n_capabilities,
+        ac_capabilities=ac_capabilities,
+        vht_bandwidth=vht_bandwidth,
+        wnm_features=wnm_features,
+    )
+    return access_point.start_ap(
+        hostapd_config=ap,
+        radvd_config=RadvdConfig() if is_ipv6_enabled else None,
+        setup_bridge=setup_bridge,
+        is_nat_enabled=is_nat_enabled,
+        additional_parameters=additional_ap_parameters,
+    )
diff --git a/packages/antlion/controllers/adb.py b/packages/antlion/controllers/adb.py
new file mode 100644
index 0000000..61597ff
--- /dev/null
+++ b/packages/antlion/controllers/adb.py
@@ -0,0 +1,294 @@
+#!/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 logging
+import re
+import shlex
+import shutil
+
+from antlion.controllers.adb_lib.error import AdbCommandError, AdbError
+from antlion.libs.proc import job
+
+DEFAULT_ADB_TIMEOUT = 60
+DEFAULT_ADB_PULL_TIMEOUT = 180
+
+ADB_REGEX = re.compile("adb:")
+# Uses a regex to be backwards compatible with previous versions of ADB
+# (N and above add the serial to the error msg).
+DEVICE_NOT_FOUND_REGEX = re.compile("error: device (?:'.*?' )?not found")
+DEVICE_OFFLINE_REGEX = re.compile("error: device offline")
+# Raised when adb forward commands fail to forward a port.
+CANNOT_BIND_LISTENER_REGEX = re.compile("error: cannot bind listener:")
+# Expected output is "Android Debug Bridge version 1.0.XX
+ADB_VERSION_REGEX = re.compile("Android Debug Bridge version 1.0.(\d+)")
+GREP_REGEX = re.compile("grep(\s+)")
+
+ROOT_USER_ID = "0"
+SHELL_USER_ID = "2000"
+
+
+def parsing_parcel_output(output):
+    """Parsing the adb output in Parcel format.
+
+    Parsing the adb output in format:
+      Result: Parcel(
+        0x00000000: 00000000 00000014 00390038 00340031 '........8.9.1.4.'
+        0x00000010: 00300038 00300030 00300030 00340032 '8.0.0.0.0.0.2.4.'
+        0x00000020: 00350034 00330035 00320038 00310033 '4.5.5.3.8.2.3.1.'
+        0x00000030: 00000000                            '....            ')
+    """
+    output = "".join(re.findall(r"'(.*)'", output))
+    return re.sub(r"[.\s]", "", output)
+
+
+class AdbProxy(object):
+    """Proxy class for ADB.
+
+    For syntactic reasons, the '-' in adb commands need to be replaced with
+    '_'. Can directly execute adb commands on an object:
+    >> adb = AdbProxy(<serial>)
+    >> adb.start_server()
+    >> adb.devices() # will return the console output of "adb devices".
+    """
+
+    def __init__(self, serial="", ssh_connection=None):
+        """Construct an instance of AdbProxy.
+
+        Args:
+            serial: str serial number of Android device from `adb devices`
+            ssh_connection: SshConnection instance if the Android device is
+                            connected to a remote host that we can reach via SSH.
+        """
+        self.serial = serial
+        self._server_local_port = None
+        adb_path = shutil.which("adb")
+        adb_cmd = [shlex.quote(adb_path)]
+        if serial:
+            adb_cmd.append(f"-s {serial}")
+        if ssh_connection is not None:
+            # Kill all existing adb processes on the remote host (if any)
+            # Note that if there are none, then pkill exits with non-zero status
+            ssh_connection.run("pkill adb", ignore_status=True)
+            # Copy over the adb binary to a temp dir
+            temp_dir = ssh_connection.run("mktemp -d").stdout.strip()
+            ssh_connection.send_file(adb_path, temp_dir)
+            # Start up a new adb server running as root from the copied binary.
+            remote_adb_cmd = "%s/adb %s root" % (
+                temp_dir,
+                "-s %s" % serial if serial else "",
+            )
+            ssh_connection.run(remote_adb_cmd)
+            # Proxy a local port to the adb server port
+            local_port = ssh_connection.create_ssh_tunnel(5037)
+            self._server_local_port = local_port
+
+        if self._server_local_port:
+            adb_cmd.append(f"-P {local_port}")
+        self.adb_str = " ".join(adb_cmd)
+        self._ssh_connection = ssh_connection
+
+    def get_user_id(self):
+        """Returns the adb user. Either 2000 (shell) or 0 (root)."""
+        return self.shell("id -u")
+
+    def is_root(self, user_id=None):
+        """Checks if the user is root.
+
+        Args:
+            user_id: if supplied, the id to check against.
+        Returns:
+            True if the user is root. False otherwise.
+        """
+        if not user_id:
+            user_id = self.get_user_id()
+        return user_id == ROOT_USER_ID
+
+    def ensure_root(self):
+        """Ensures the user is root after making this call.
+
+        Note that this will still fail if the device is a user build, as root
+        is not accessible from a user build.
+
+        Returns:
+            False if the device is a user build. True otherwise.
+        """
+        self.ensure_user(ROOT_USER_ID)
+        return self.is_root()
+
+    def ensure_user(self, user_id=SHELL_USER_ID):
+        """Ensures the user is set to the given user.
+
+        Args:
+            user_id: The id of the user.
+        """
+        if self.is_root(user_id):
+            self.root()
+        else:
+            self.unroot()
+        self.wait_for_device()
+        return self.get_user_id() == user_id
+
+    def _exec_cmd(self, cmd, ignore_status=False, timeout=DEFAULT_ADB_TIMEOUT):
+        """Executes adb commands in a new shell.
+
+        This is specific to executing adb commands.
+
+        Args:
+            cmd: A string or list that is the adb command to execute.
+
+        Returns:
+            The stdout of the adb command.
+
+        Raises:
+            AdbError for errors in ADB operations.
+            AdbCommandError for errors from commands executed through ADB.
+        """
+        if isinstance(cmd, list):
+            cmd = " ".join(cmd)
+        result = job.run(cmd, ignore_status=True, timeout_sec=timeout)
+        ret, out, err = result.exit_status, result.stdout, result.stderr
+
+        if any(
+            pattern.match(err)
+            for pattern in [
+                ADB_REGEX,
+                DEVICE_OFFLINE_REGEX,
+                DEVICE_NOT_FOUND_REGEX,
+                CANNOT_BIND_LISTENER_REGEX,
+            ]
+        ):
+            raise AdbError(cmd=cmd, stdout=out, stderr=err, ret_code=ret)
+        if "Result: Parcel" in out:
+            return parsing_parcel_output(out)
+        if ignore_status or (ret == 1 and GREP_REGEX.search(cmd)):
+            return out or err
+        if ret != 0:
+            raise AdbCommandError(cmd=cmd, stdout=out, stderr=err, ret_code=ret)
+        return out
+
+    def _exec_adb_cmd(self, name, arg_str, **kwargs):
+        return self._exec_cmd(f"{self.adb_str} {name} {arg_str}", **kwargs)
+
+    def _exec_cmd_nb(self, cmd, **kwargs):
+        """Executes adb commands in a new shell, non blocking.
+
+        Args:
+            cmds: A string that is the adb command to execute.
+
+        """
+        return job.run_async(cmd, **kwargs)
+
+    def _exec_adb_cmd_nb(self, name, arg_str, **kwargs):
+        return self._exec_cmd_nb(f"{self.adb_str} {name} {arg_str}", **kwargs)
+
+    def tcp_forward(self, host_port, device_port):
+        """Starts tcp forwarding from localhost to this android device.
+
+        Args:
+            host_port: Port number to use on localhost
+            device_port: Port number to use on the android device.
+
+        Returns:
+            Forwarded port on host as int or command output string on error
+        """
+        if self._ssh_connection:
+            # We have to hop through a remote host first.
+            #  1) Find some free port on the remote host's localhost
+            #  2) Setup forwarding between that remote port and the requested
+            #     device port
+            remote_port = self._ssh_connection.find_free_port()
+            host_port = self._ssh_connection.create_ssh_tunnel(
+                remote_port, local_port=host_port
+            )
+        output = self.forward(f"tcp:{host_port} tcp:{device_port}", ignore_status=True)
+        # If hinted_port is 0, the output will be the selected port.
+        # Otherwise, there will be no output upon successfully
+        # forwarding the hinted port.
+        if not output:
+            return host_port
+        try:
+            output_int = int(output)
+        except ValueError:
+            return output
+        return output_int
+
+    def remove_tcp_forward(self, host_port):
+        """Stop tcp forwarding a port from localhost to this android device.
+
+        Args:
+            host_port: Port number to use on localhost
+        """
+        if self._ssh_connection:
+            remote_port = self._ssh_connection.close_ssh_tunnel(host_port)
+            if remote_port is None:
+                logging.warning(
+                    "Cannot close unknown forwarded tcp port: %d", host_port
+                )
+                return
+            # The actual port we need to disable via adb is on the remote host.
+            host_port = remote_port
+        self.forward(f"--remove tcp:{host_port}")
+
+    def getprop(self, prop_name):
+        """Get a property of the device.
+
+        This is a convenience wrapper for "adb shell getprop xxx".
+
+        Args:
+            prop_name: A string that is the name of the property to get.
+
+        Returns:
+            A string that is the value of the property, or None if the property
+            doesn't exist.
+        """
+        return self.shell(f"getprop {prop_name}")
+
+    # TODO: This should be abstracted out into an object like the other shell
+    # command.
+    def shell(self, command, ignore_status=False, timeout=DEFAULT_ADB_TIMEOUT):
+        return self._exec_adb_cmd(
+            "shell", shlex.quote(command), ignore_status=ignore_status, timeout=timeout
+        )
+
+    def shell_nb(self, command):
+        return self._exec_adb_cmd_nb("shell", shlex.quote(command))
+
+    def __getattr__(self, name):
+        def adb_call(*args, **kwargs):
+            clean_name = name.replace("_", "-")
+            if clean_name in ["pull", "push", "remount"] and "timeout" not in kwargs:
+                kwargs["timeout"] = DEFAULT_ADB_PULL_TIMEOUT
+            arg_str = " ".join(str(elem) for elem in args)
+            return self._exec_adb_cmd(clean_name, arg_str, **kwargs)
+
+        return adb_call
+
+    def get_version_number(self):
+        """Returns the version number of ADB as an int (XX in 1.0.XX).
+
+        Raises:
+            AdbError if the version number is not found/parsable.
+        """
+        version_output = self.version()
+        match = re.search(ADB_VERSION_REGEX, version_output)
+
+        if not match:
+            logging.error(
+                "Unable to capture ADB version from adb version "
+                "output: %s" % version_output
+            )
+            raise AdbError("adb version", version_output, "", "")
+        return int(match.group(1))
diff --git a/src/antlion/controllers/adb_lib/__init__.py b/packages/antlion/controllers/adb_lib/__init__.py
similarity index 100%
rename from src/antlion/controllers/adb_lib/__init__.py
rename to packages/antlion/controllers/adb_lib/__init__.py
diff --git a/packages/antlion/controllers/adb_lib/error.py b/packages/antlion/controllers/adb_lib/error.py
new file mode 100644
index 0000000..9599214
--- /dev/null
+++ b/packages/antlion/controllers/adb_lib/error.py
@@ -0,0 +1,40 @@
+#!/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.
+
+from antlion import error
+
+
+class AdbError(error.ActsError):
+    """Raised when there is an error in adb operations."""
+
+    def __init__(self, cmd, stdout, stderr, ret_code):
+        super().__init__()
+        self.cmd = cmd
+        self.stdout = stdout
+        self.stderr = stderr
+        self.ret_code = ret_code
+
+    def __str__(self):
+        return ("Error executing adb cmd '%s'. ret: %d, stdout: %s, stderr: %s") % (
+            self.cmd,
+            self.ret_code,
+            self.stdout,
+            self.stderr,
+        )
+
+
+class AdbCommandError(AdbError):
+    """Raised when there is an error in the command being run through ADB."""
diff --git a/packages/antlion/controllers/android_device.py b/packages/antlion/controllers/android_device.py
new file mode 100755
index 0000000..87c7b94
--- /dev/null
+++ b/packages/antlion/controllers/android_device.py
@@ -0,0 +1,1807 @@
+#!/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.
+
+from __future__ import annotations
+
+import collections
+import logging
+import math
+import os
+import re
+import shutil
+import socket
+import time
+from datetime import datetime
+
+from antlion import context
+from antlion import logger as acts_logger
+from antlion import utils
+from antlion.controllers import adb, fastboot
+from antlion.controllers.adb_lib.error import AdbError
+from antlion.controllers.android_lib import errors
+from antlion.controllers.android_lib import events as android_events
+from antlion.controllers.android_lib import logcat, services
+from antlion.controllers.sl4a_lib import sl4a_manager
+from antlion.controllers.utils_lib.ssh import connection, settings
+from antlion.event import event_bus
+from antlion.libs.proc import job
+from antlion.runner import Runner
+from antlion.types import ControllerConfig, Json
+
+MOBLY_CONTROLLER_CONFIG_NAME: str = "AndroidDevice"
+ACTS_CONTROLLER_REFERENCE_NAME = "android_devices"
+
+ANDROID_DEVICE_PICK_ALL_TOKEN = "*"
+# Key name for SL4A extra params in config file
+ANDROID_DEVICE_SL4A_CLIENT_PORT_KEY = "sl4a_client_port"
+ANDROID_DEVICE_SL4A_FORWARDED_PORT_KEY = "sl4a_forwarded_port"
+ANDROID_DEVICE_SL4A_SERVER_PORT_KEY = "sl4a_server_port"
+# Key name for adb logcat extra params in config file.
+ANDROID_DEVICE_ADB_LOGCAT_PARAM_KEY = "adb_logcat_param"
+ANDROID_DEVICE_EMPTY_CONFIG_MSG = "Configuration is empty, abort!"
+ANDROID_DEVICE_NOT_LIST_CONFIG_MSG = "Configuration should be a list, abort!"
+CRASH_REPORT_PATHS = (
+    "/data/tombstones/",
+    "/data/vendor/ramdump/",
+    "/data/ramdump/",
+    "/data/vendor/ssrdump",
+    "/data/vendor/ramdump/bluetooth",
+    "/data/vendor/log/cbd",
+)
+CRASH_REPORT_SKIPS = (
+    "RAMDUMP_RESERVED",
+    "RAMDUMP_STATUS",
+    "RAMDUMP_OUTPUT",
+    "bluetooth",
+)
+ALWAYS_ON_LOG_PATH = "/data/vendor/radio/logs/always-on"
+DEFAULT_QXDM_LOG_PATH = "/data/vendor/radio/diag_logs"
+DEFAULT_SDM_LOG_PATH = "/data/vendor/slog/"
+DEFAULT_SCREENSHOT_PATH = "/sdcard/Pictures/screencap"
+BUG_REPORT_TIMEOUT = 1800
+PULL_TIMEOUT = 300
+PORT_RETRY_COUNT = 3
+ADB_ROOT_RETRY_COUNT = 2
+ADB_ROOT_RETRY_INTERVAL = 10
+IPERF_TIMEOUT = 60
+SL4A_APK_NAME = "com.googlecode.android_scripting"
+WAIT_FOR_DEVICE_TIMEOUT = 180
+ENCRYPTION_WINDOW = "CryptKeeper"
+DEFAULT_DEVICE_PASSWORD = "1111"
+RELEASE_ID_REGEXES = [re.compile(r"\w+\.\d+\.\d+"), re.compile(r"N\w+")]
+
+
+def create(configs: list[ControllerConfig]) -> list[AndroidDevice]:
+    """Creates AndroidDevice controller objects.
+
+    Args:
+        configs: A list of dicts, each representing a configuration for an
+                 Android device.
+
+    Returns:
+        A list of AndroidDevice objects.
+    """
+    if not configs:
+        raise errors.AndroidDeviceConfigError(ANDROID_DEVICE_EMPTY_CONFIG_MSG)
+    elif not isinstance(configs, list):
+        raise errors.AndroidDeviceConfigError(ANDROID_DEVICE_NOT_LIST_CONFIG_MSG)
+    elif isinstance(configs[0], str):
+        # Configs is a list of serials.
+        ads = get_instances(configs)
+    else:
+        # Configs is a list of dicts.
+        ads = get_instances_with_configs(configs)
+
+    ads[0].log.info(f'The primary device under test is "{ads[0].serial}".')
+
+    for ad in ads:
+        if not ad.is_connected():
+            raise errors.AndroidDeviceError(
+                ("Android device %s is specified in config" " but is not attached.")
+                % ad.serial,
+                serial=ad.serial,
+            )
+    _start_services_on_ads(ads)
+    for ad in ads:
+        if ad.droid:
+            utils.set_location_service(ad, False)
+            utils.sync_device_time(ad)
+    return ads
+
+
+def destroy(objects: list[AndroidDevice]) -> None:
+    """Cleans up AndroidDevice objects.
+
+    Args:
+        ads: A list of AndroidDevice objects.
+    """
+    for ad in objects:
+        try:
+            ad.clean_up()
+        except:
+            ad.log.exception("Failed to clean up properly.")
+
+
+def get_info(objects: list[AndroidDevice]) -> list[Json]:
+    """Get information on a list of AndroidDevice objects.
+
+    Args:
+        ads: A list of AndroidDevice objects.
+
+    Returns:
+        A list of dict, each representing info for an AndroidDevice objects.
+    """
+    device_info: list[Json] = []
+    for ad in objects:
+        info = {"serial": ad.serial, "model": ad.model}
+        info.update(ad.build_info)
+        device_info.append(info)
+    return device_info
+
+
+def _start_services_on_ads(ads):
+    """Starts long running services on multiple AndroidDevice objects.
+
+    If any one AndroidDevice object fails to start services, cleans up all
+    existing AndroidDevice objects and their services.
+
+    Args:
+        ads: A list of AndroidDevice objects whose services to start.
+    """
+    running_ads = []
+    for ad in ads:
+        running_ads.append(ad)
+        try:
+            ad.start_services()
+        except:
+            ad.log.exception("Failed to start some services, abort!")
+            destroy(running_ads)
+            raise
+
+
+def _parse_device_list(device_list_str, key):
+    """Parses a byte string representing a list of devices. The string is
+    generated by calling either adb or fastboot.
+
+    Args:
+        device_list_str: Output of adb or fastboot.
+        key: The token that signifies a device in device_list_str.
+
+    Returns:
+        A list of android device serial numbers.
+    """
+    return re.findall(r"(\S+)\t%s" % key, device_list_str)
+
+
+def list_adb_devices():
+    """List all android devices connected to the computer that are detected by
+    adb.
+
+    Returns:
+        A list of android device serials. Empty if there's none.
+    """
+    out = adb.AdbProxy().devices()
+    return _parse_device_list(out, "device")
+
+
+def list_fastboot_devices():
+    """List all android devices connected to the computer that are in in
+    fastboot mode. These are detected by fastboot.
+
+    Returns:
+        A list of android device serials. Empty if there's none.
+    """
+    out = fastboot.FastbootProxy().devices()
+    return _parse_device_list(out, "fastboot")
+
+
+def get_instances(serials) -> list[AndroidDevice]:
+    """Create AndroidDevice instances from a list of serials.
+
+    Args:
+        serials: A list of android device serials.
+
+    Returns:
+        A list of AndroidDevice objects.
+    """
+    results: list[AndroidDevice] = []
+    for s in serials:
+        results.append(AndroidDevice(s))
+    return results
+
+
+def get_instances_with_configs(configs):
+    """Create AndroidDevice instances from a list of json configs.
+
+    Each config should have the required key-value pair "serial".
+
+    Args:
+        configs: A list of dicts each representing the configuration of one
+            android device.
+
+    Returns:
+        A list of AndroidDevice objects.
+    """
+    results = []
+    for c in configs:
+        try:
+            serial = c.pop("serial")
+        except KeyError:
+            raise errors.AndroidDeviceConfigError(
+                f"Required value 'serial' is missing in AndroidDevice config {c}."
+            )
+        client_port = 0
+        if ANDROID_DEVICE_SL4A_CLIENT_PORT_KEY in c:
+            try:
+                client_port = int(c.pop(ANDROID_DEVICE_SL4A_CLIENT_PORT_KEY))
+            except ValueError:
+                raise errors.AndroidDeviceConfigError(
+                    "'%s' is not a valid number for config %s"
+                    % (ANDROID_DEVICE_SL4A_CLIENT_PORT_KEY, c)
+                )
+        server_port = None
+        if ANDROID_DEVICE_SL4A_SERVER_PORT_KEY in c:
+            try:
+                server_port = int(c.pop(ANDROID_DEVICE_SL4A_SERVER_PORT_KEY))
+            except ValueError:
+                raise errors.AndroidDeviceConfigError(
+                    "'%s' is not a valid number for config %s"
+                    % (ANDROID_DEVICE_SL4A_SERVER_PORT_KEY, c)
+                )
+        forwarded_port = 0
+        if ANDROID_DEVICE_SL4A_FORWARDED_PORT_KEY in c:
+            try:
+                forwarded_port = int(c.pop(ANDROID_DEVICE_SL4A_FORWARDED_PORT_KEY))
+            except ValueError:
+                raise errors.AndroidDeviceConfigError(
+                    "'%s' is not a valid number for config %s"
+                    % (ANDROID_DEVICE_SL4A_FORWARDED_PORT_KEY, c)
+                )
+        ssh_config = c.pop("ssh_config", None)
+        ssh_connection = None
+        if ssh_config is not None:
+            ssh_settings = settings.from_config(ssh_config)
+            ssh_connection = connection.SshConnection(ssh_settings)
+        ad = AndroidDevice(
+            serial,
+            ssh_connection=ssh_connection,
+            client_port=client_port,
+            forwarded_port=forwarded_port,
+            server_port=server_port,
+        )
+        ad.load_config(c)
+        results.append(ad)
+    return results
+
+
+def get_all_instances(include_fastboot: bool = False) -> list[AndroidDevice]:
+    """Create AndroidDevice instances for all attached android devices.
+
+    Args:
+        include_fastboot: Whether to include devices in bootloader mode or not.
+
+    Returns:
+        A list of AndroidDevice objects each representing an android device
+        attached to the computer.
+    """
+    if include_fastboot:
+        serial_list = list_adb_devices() + list_fastboot_devices()
+        return get_instances(serial_list)
+    return get_instances(list_adb_devices())
+
+
+def filter_devices(ads, func):
+    """Finds the AndroidDevice instances from a list that match certain
+    conditions.
+
+    Args:
+        ads: A list of AndroidDevice instances.
+        func: A function that takes an AndroidDevice object and returns True
+            if the device satisfies the filter condition.
+
+    Returns:
+        A list of AndroidDevice instances that satisfy the filter condition.
+    """
+    results = []
+    for ad in ads:
+        if func(ad):
+            results.append(ad)
+    return results
+
+
+def get_device(ads, **kwargs):
+    """Finds a unique AndroidDevice instance from a list that has specific
+    attributes of certain values.
+
+    Example:
+        get_device(android_devices, label="foo", phone_number="1234567890")
+        get_device(android_devices, model="angler")
+
+    Args:
+        ads: A list of AndroidDevice instances.
+        kwargs: keyword arguments used to filter AndroidDevice instances.
+
+    Returns:
+        The target AndroidDevice instance.
+
+    Raises:
+        AndroidDeviceError is raised if none or more than one device is
+        matched.
+    """
+
+    def _get_device_filter(ad):
+        for k, v in kwargs.items():
+            if not hasattr(ad, k):
+                return False
+            elif getattr(ad, k) != v:
+                return False
+        return True
+
+    filtered = filter_devices(ads, _get_device_filter)
+    if not filtered:
+        raise ValueError(
+            f"Could not find a target device that matches condition: {kwargs}."
+        )
+    elif len(filtered) == 1:
+        return filtered[0]
+    else:
+        serials = [ad.serial for ad in filtered]
+        raise ValueError(f"More than one device matched: {serials}")
+
+
+def take_bug_reports(ads, test_name, begin_time):
+    """Takes bug reports on a list of android devices.
+
+    If you want to take a bug report, call this function with a list of
+    android_device objects in on_fail. But reports will be taken on all the
+    devices in the list concurrently. Bug report takes a relative long
+    time to take, so use this cautiously.
+
+    Args:
+        ads: A list of AndroidDevice instances.
+        test_name: Name of the test case that triggered this bug report.
+        begin_time: Logline format timestamp taken when the test started.
+    """
+
+    def take_br(test_name, begin_time, ad):
+        ad.take_bug_report(test_name, begin_time)
+
+    args = [(test_name, begin_time, ad) for ad in ads]
+    utils.concurrent_exec(take_br, args)
+
+
+class AndroidDevice:
+    """Class representing an android device.
+
+    Each object of this class represents one Android device in ACTS, including
+    handles to adb, fastboot, and sl4a clients. In addition to direct adb
+    commands, this object also uses adb port forwarding to talk to the Android
+    device.
+
+    Attributes:
+        serial: A string that's the serial number of the Android device.
+        log_path: A string that is the path where all logs collected on this
+                  android device should be stored.
+        log: A logger adapted from root logger with added token specific to an
+             AndroidDevice instance.
+        adb_logcat_process: A process that collects the adb logcat.
+        adb: An AdbProxy object used for interacting with the device via adb.
+        fastboot: A FastbootProxy object used for interacting with the device
+                  via fastboot.
+        client_port: Preferred client port number on the PC host side for SL4A
+        forwarded_port: Preferred server port number forwarded from Android
+                        to the host PC via adb for SL4A connections
+        server_port: Preferred server port used by SL4A on Android device
+
+    """
+
+    def __init__(
+        self,
+        serial: str = "",
+        ssh_connection: Runner | None = None,
+        client_port: int = 0,
+        forwarded_port: int = 0,
+        server_port: int | None = None,
+    ):
+        self.serial = serial
+        # logging.log_path only exists when this is used in an ACTS test run.
+        log_path_base = getattr(logging, "log_path", "/tmp/logs")
+        self.log_dir = f"AndroidDevice{serial}"
+        self.log_path = os.path.join(log_path_base, self.log_dir)
+        self.client_port = client_port
+        self.forwarded_port = forwarded_port
+        self.server_port = server_port
+        self.log = AndroidDeviceLoggerAdapter(logging.getLogger(), {"serial": serial})
+        self._event_dispatchers = {}
+        self._services = []
+        self.register_service(services.AdbLogcatService(self))
+        self.register_service(services.Sl4aService(self))
+        self.adb_logcat_process = None
+        self.adb = adb.AdbProxy(serial, ssh_connection=ssh_connection)
+        self.fastboot = fastboot.FastbootProxy(serial, ssh_connection=ssh_connection)
+        if not self.is_bootloader:
+            self.root_adb()
+        self._ssh_connection = ssh_connection
+        self.skip_sl4a = False
+        self.crash_report = None
+        self.data_accounting = collections.defaultdict(int)
+        self._sl4a_manager = sl4a_manager.create_sl4a_manager(self.adb)
+        self.last_logcat_timestamp = None
+        # Device info cache.
+        self._user_added_device_info = {}
+        self._sdk_api_level = None
+
+    def clean_up(self):
+        """Cleans up the AndroidDevice object and releases any resources it
+        claimed.
+        """
+        self.stop_services()
+        for service in self._services:
+            service.unregister()
+        self._services.clear()
+        if self._ssh_connection:
+            self._ssh_connection.close()
+
+    def recreate_services(self, serial):
+        """Clean up the AndroidDevice object and re-create adb/sl4a services.
+
+        Unregister the existing services and re-create adb and sl4a services,
+        call this method when the connection break after certain API call
+        (e.g., enable USB tethering by #startTethering)
+
+        Args:
+            serial: the serial number of the AndroidDevice
+        """
+        # Clean the old services
+        for service in self._services:
+            service.unregister()
+        self._services.clear()
+        if self._ssh_connection:
+            self._ssh_connection.close()
+        self._sl4a_manager.stop_service()
+
+        # Wait for old services to stop
+        time.sleep(5)
+
+        # Re-create the new adb and sl4a services
+        self.register_service(services.AdbLogcatService(self))
+        self.register_service(services.Sl4aService(self))
+        self.adb.wait_for_device()
+        self.terminate_all_sessions()
+        self.start_services()
+
+    def register_service(self, service):
+        """Registers the service on the device."""
+        service.register()
+        self._services.append(service)
+
+    # TODO(angli): This function shall be refactored to accommodate all services
+    # and not have hard coded switch for SL4A when b/29157104 is done.
+    def start_services(self, skip_setup_wizard=True):
+        """Starts long running services on the android device.
+
+        1. Start adb logcat capture.
+        2. Start SL4A if not skipped.
+
+        Args:
+            skip_setup_wizard: Whether or not to skip the setup wizard.
+        """
+        if skip_setup_wizard:
+            self.exit_setup_wizard()
+
+        event_bus.post(android_events.AndroidStartServicesEvent(self))
+
+    def stop_services(self):
+        """Stops long running services on the android device.
+
+        Stop adb logcat and terminate sl4a sessions if exist.
+        """
+        event_bus.post(
+            android_events.AndroidStopServicesEvent(self), ignore_errors=True
+        )
+
+    def is_connected(self):
+        out = self.adb.devices()
+        devices = _parse_device_list(out, "device")
+        return self.serial in devices
+
+    @property
+    def build_info(self):
+        """Get the build info of this Android device, including build id and
+        build type.
+
+        This is not available if the device is in bootloader mode.
+
+        Returns:
+            A dict with the build info of this Android device, or None if the
+            device is in bootloader mode.
+        """
+        if self.is_bootloader:
+            self.log.error("Device is in fastboot mode, could not get build " "info.")
+            return
+
+        build_id = self.adb.getprop("ro.build.id")
+        incremental_build_id = self.adb.getprop("ro.build.version.incremental")
+        valid_build_id = False
+        for regex in RELEASE_ID_REGEXES:
+            if re.match(regex, build_id):
+                valid_build_id = True
+                break
+        if not valid_build_id:
+            build_id = incremental_build_id
+
+        info = {
+            "build_id": build_id,
+            "incremental_build_id": incremental_build_id,
+            "build_type": self.adb.getprop("ro.build.type"),
+        }
+        return info
+
+    @property
+    def device_info(self):
+        """Information to be pulled into controller info.
+
+        The latest serial, model, and build_info are included. Additional info
+        can be added via `add_device_info`.
+        """
+        info = {
+            "serial": self.serial,
+            "model": self.model,
+            "build_info": self.build_info,
+            "user_added_info": self._user_added_device_info,
+            "flavor": self.flavor,
+        }
+        return info
+
+    def add_device_info(self, name, info):
+        """Add custom device info to the user_added_info section.
+
+        Adding the same info name the second time will override existing info.
+
+        Args:
+          name: string, name of this info.
+          info: serializable, content of the info.
+        """
+        self._user_added_device_info.update({name: info})
+
+    def sdk_api_level(self):
+        if self._sdk_api_level is not None:
+            return self._sdk_api_level
+        if self.is_bootloader:
+            self.log.error("Device is in fastboot mode. Cannot get build info.")
+            return
+        self._sdk_api_level = int(self.adb.shell("getprop ro.build.version.sdk"))
+        return self._sdk_api_level
+
+    @property
+    def is_bootloader(self):
+        """True if the device is in bootloader mode."""
+        return self.serial in list_fastboot_devices()
+
+    @property
+    def is_adb_root(self):
+        """True if adb is running as root for this device."""
+        try:
+            return "0" == self.adb.shell("id -u")
+        except AdbError:
+            # Wait a bit and retry to work around adb flakiness for this cmd.
+            time.sleep(0.2)
+            return "0" == self.adb.shell("id -u")
+
+    @property
+    def model(self):
+        """The Android code name for the device."""
+        # If device is in bootloader mode, get mode name from fastboot.
+        if self.is_bootloader:
+            out = self.fastboot.getvar("product").strip()
+            # "out" is never empty because of the "total time" message fastboot
+            # writes to stderr.
+            lines = out.split("\n", 1)
+            if lines:
+                tokens = lines[0].split(" ")
+                if len(tokens) > 1:
+                    return tokens[1].lower()
+            return None
+        model = self.adb.getprop("ro.build.product").lower()
+        if model == "sprout":
+            return model
+        else:
+            return self.adb.getprop("ro.product.name").lower()
+
+    @property
+    def flavor(self):
+        """Returns the specific flavor of Android build the device is using."""
+        return self.adb.getprop("ro.build.flavor").lower()
+
+    @property
+    def droid(self):
+        """Returns the RPC Service of the first Sl4aSession created."""
+        if len(self._sl4a_manager.sessions) > 0:
+            session_id = sorted(self._sl4a_manager.sessions.keys())[0]
+            return self._sl4a_manager.sessions[session_id].rpc_client
+        else:
+            return None
+
+    @property
+    def ed(self):
+        """Returns the event dispatcher of the first Sl4aSession created."""
+        if len(self._sl4a_manager.sessions) > 0:
+            session_id = sorted(self._sl4a_manager.sessions.keys())[0]
+            return self._sl4a_manager.sessions[session_id].get_event_dispatcher()
+        else:
+            return None
+
+    @property
+    def sl4a_sessions(self):
+        """Returns a dictionary of session ids to sessions."""
+        return list(self._sl4a_manager.sessions)
+
+    @property
+    def is_adb_logcat_on(self):
+        """Whether there is an ongoing adb logcat collection."""
+        if self.adb_logcat_process:
+            if self.adb_logcat_process.is_running():
+                return True
+            else:
+                # if skip_sl4a is true, there is no sl4a session
+                # if logcat died due to device reboot and sl4a session has
+                # not restarted there is no droid.
+                if self.droid:
+                    self.droid.logI("Logcat died")
+                self.log.info("Logcat to %s died", self.log_path)
+                return False
+        return False
+
+    @property
+    def device_log_path(self):
+        """Returns the directory for all Android device logs for the current
+        test context and serial.
+        """
+        return context.get_current_context().get_full_output_path(self.serial)
+
+    def update_sdk_api_level(self):
+        self._sdk_api_level = None
+        self.sdk_api_level()
+
+    def load_config(self, config):
+        """Add attributes to the AndroidDevice object based on json config.
+
+        Args:
+            config: A dictionary representing the configs.
+
+        Raises:
+            AndroidDeviceError is raised if the config is trying to overwrite
+            an existing attribute.
+        """
+        for k, v in config.items():
+            # skip_sl4a value can be reset from config file
+            if hasattr(self, k) and k != "skip_sl4a":
+                raise errors.AndroidDeviceError(
+                    f"Attempting to set existing attribute {k} on {self.serial}",
+                    serial=self.serial,
+                )
+            setattr(self, k, v)
+
+    def root_adb(self):
+        """Change adb to root mode for this device if allowed.
+
+        If executed on a production build, adb will not be switched to root
+        mode per security restrictions.
+        """
+        if self.is_adb_root:
+            return
+
+        for attempt in range(ADB_ROOT_RETRY_COUNT):
+            try:
+                self.log.debug(f"Enabling ADB root mode: attempt {attempt}.")
+                self.adb.root()
+            except AdbError:
+                if attempt == ADB_ROOT_RETRY_COUNT:
+                    raise
+                time.sleep(ADB_ROOT_RETRY_INTERVAL)
+        self.adb.wait_for_device()
+
+    def get_droid(self, handle_event=True):
+        """Create an sl4a connection to the device.
+
+        Return the connection handler 'droid'. By default, another connection
+        on the same session is made for EventDispatcher, and the dispatcher is
+        returned to the caller as well.
+        If sl4a server is not started on the device, try to start it.
+
+        Args:
+            handle_event: True if this droid session will need to handle
+                events.
+
+        Returns:
+            droid: Android object used to communicate with sl4a on the android
+                device.
+            ed: An optional EventDispatcher to organize events for this droid.
+
+        Examples:
+            Don't need event handling:
+            >>> ad = AndroidDevice()
+            >>> droid = ad.get_droid(False)
+
+            Need event handling:
+            >>> ad = AndroidDevice()
+            >>> droid, ed = ad.get_droid()
+        """
+        self.log.debug(
+            "Creating RPC client_port={}, forwarded_port={}, server_port={}".format(
+                self.client_port, self.forwarded_port, self.server_port
+            )
+        )
+        session = self._sl4a_manager.create_session(
+            client_port=self.client_port,
+            forwarded_port=self.forwarded_port,
+            server_port=self.server_port,
+        )
+        droid = session.rpc_client
+        if handle_event:
+            ed = session.get_event_dispatcher()
+            return droid, ed
+        return droid
+
+    def get_package_pid(self, package_name):
+        """Gets the pid for a given package. Returns None if not running.
+        Args:
+            package_name: The name of the package.
+        Returns:
+            The first pid found under a given package name. None if no process
+            was found running the package.
+        Raises:
+            AndroidDeviceError if the output of the phone's process list was
+            in an unexpected format.
+        """
+        for cmd in ("ps -A", "ps"):
+            try:
+                out = self.adb.shell(
+                    f'{cmd} | grep "S {package_name}"', ignore_status=True
+                )
+                if package_name not in out:
+                    continue
+                try:
+                    pid = int(out.split()[1])
+                    self.log.info("apk %s has pid %s.", package_name, pid)
+                    return pid
+                except (IndexError, ValueError) as e:
+                    # Possible ValueError from string to int cast.
+                    # Possible IndexError from split.
+                    self.log.warning(
+                        'Command "%s" returned output line: ' '"%s".\nError: %s',
+                        cmd,
+                        out,
+                        e,
+                    )
+            except Exception as e:
+                self.log.warning(
+                    'Device fails to check if %s running with "%s"\n' "Exception %s",
+                    package_name,
+                    cmd,
+                    e,
+                )
+        self.log.debug("apk %s is not running", package_name)
+        return None
+
+    def get_dispatcher(self, droid):
+        """Return an EventDispatcher for an sl4a session
+
+        Args:
+            droid: Session to create EventDispatcher for.
+
+        Returns:
+            ed: An EventDispatcher for specified session.
+        """
+        return self._sl4a_manager.sessions[droid.uid].get_event_dispatcher()
+
+    def _is_timestamp_in_range(self, target, log_begin_time, log_end_time):
+        low = acts_logger.logline_timestamp_comparator(log_begin_time, target) <= 0
+        high = acts_logger.logline_timestamp_comparator(log_end_time, target) >= 0
+        return low and high
+
+    def cat_adb_log(self, tag, begin_time, end_time=None, dest_path="AdbLogExcerpts"):
+        """Takes an excerpt of the adb logcat log from a certain time point to
+        current time.
+
+        Args:
+            tag: An identifier of the time period, usually the name of a test.
+            begin_time: Epoch time of the beginning of the time period.
+            end_time: Epoch time of the ending of the time period, default None
+            dest_path: Destination path of the excerpt file.
+        """
+        log_begin_time = acts_logger.epoch_to_log_line_timestamp(begin_time)
+        if end_time is None:
+            log_end_time = acts_logger.get_log_line_timestamp()
+        else:
+            log_end_time = acts_logger.epoch_to_log_line_timestamp(end_time)
+        self.log.debug("Extracting adb log from logcat.")
+        logcat_path = os.path.join(
+            self.device_log_path, f"adblog_{self.serial}_debug.txt"
+        )
+        if not os.path.exists(logcat_path):
+            self.log.warning(f"Logcat file {logcat_path} does not exist.")
+            return
+        adb_excerpt_dir = os.path.join(self.log_path, dest_path)
+        os.makedirs(adb_excerpt_dir, exist_ok=True)
+        out_name = "%s,%s.txt" % (
+            acts_logger.normalize_log_line_timestamp(log_begin_time),
+            self.serial,
+        )
+        tag_len = utils.MAX_FILENAME_LEN - len(out_name)
+        out_name = f"{tag[:tag_len]},{out_name}"
+        adb_excerpt_path = os.path.join(adb_excerpt_dir, out_name)
+        with open(adb_excerpt_path, "w", encoding="utf-8") as out:
+            in_file = logcat_path
+            with open(in_file, "r", encoding="utf-8", errors="replace") as f:
+                while True:
+                    line = None
+                    try:
+                        line = f.readline()
+                        if not line:
+                            break
+                    except:
+                        continue
+                    line_time = line[: acts_logger.log_line_timestamp_len]
+                    if not acts_logger.is_valid_logline_timestamp(line_time):
+                        continue
+                    if self._is_timestamp_in_range(
+                        line_time, log_begin_time, log_end_time
+                    ):
+                        if not line.endswith("\n"):
+                            line += "\n"
+                        out.write(line)
+        return adb_excerpt_path
+
+    def search_logcat(
+        self, matching_string, begin_time=None, end_time=None, logcat_path=None
+    ):
+        """Search logcat message with given string.
+
+        Args:
+            matching_string: matching_string to search.
+            begin_time: only the lines with time stamps later than begin_time
+                will be searched.
+            end_time: only the lines with time stamps earlier than end_time
+                will be searched.
+            logcat_path: the path of a specific file in which the search should
+                be performed. If None the path will be the default device log
+                path.
+
+        Returns:
+            A list of dictionaries with full log message, time stamp string,
+            time object and message ID. For example:
+            [{"log_message": "05-03 17:39:29.898   968  1001 D"
+                              "ActivityManager: Sending BOOT_COMPLETE user #0",
+              "time_stamp": "2017-05-03 17:39:29.898",
+              "datetime_obj": datetime object,
+              "message_id": None}]
+
+            [{"log_message": "08-12 14:26:42.611043  2360  2510 D RILJ    : "
+                             "[0853]< DEACTIVATE_DATA_CALL  [PHONE0]",
+              "time_stamp": "2020-08-12 14:26:42.611043",
+              "datetime_obj": datetime object},
+              "message_id": "0853"}]
+        """
+        if not logcat_path:
+            logcat_path = os.path.join(
+                self.device_log_path, f"adblog_{self.serial}_debug.txt"
+            )
+        if not os.path.exists(logcat_path):
+            self.log.warning(f"Logcat file {logcat_path} does not exist.")
+            return
+        output = job.run(f"grep '{matching_string}' {logcat_path}", ignore_status=True)
+        if not output.stdout or output.exit_status != 0:
+            return []
+        if begin_time:
+            if not isinstance(begin_time, datetime):
+                log_begin_time = acts_logger.epoch_to_log_line_timestamp(begin_time)
+                begin_time = datetime.strptime(log_begin_time, "%Y-%m-%d %H:%M:%S.%f")
+        if end_time:
+            if not isinstance(end_time, datetime):
+                log_end_time = acts_logger.epoch_to_log_line_timestamp(end_time)
+                end_time = datetime.strptime(log_end_time, "%Y-%m-%d %H:%M:%S.%f")
+        result = []
+        logs = re.findall(r"(\S+\s\S+)(.*)", output.stdout)
+        for log in logs:
+            time_stamp = log[0]
+            time_obj = datetime.strptime(time_stamp, "%Y-%m-%d %H:%M:%S.%f")
+
+            if begin_time and time_obj < begin_time:
+                continue
+
+            if end_time and time_obj > end_time:
+                continue
+
+            res = re.findall(r".*\[(\d+)\]", log[1])
+            try:
+                message_id = res[0]
+            except:
+                message_id = None
+
+            result.append(
+                {
+                    "log_message": "".join(log),
+                    "time_stamp": time_stamp,
+                    "datetime_obj": time_obj,
+                    "message_id": message_id,
+                }
+            )
+        return result
+
+    def start_adb_logcat(self):
+        """Starts a standing adb logcat collection in separate subprocesses and
+        save the logcat in a file.
+        """
+        if self.is_adb_logcat_on:
+            self.log.warning(
+                "Android device %s already has a running adb logcat thread. "
+                % self.serial
+            )
+            return
+        # Disable adb log spam filter. Have to stop and clear settings first
+        # because 'start' doesn't support --clear option before Android N.
+        self.adb.shell("logpersist.stop --clear", ignore_status=True)
+        self.adb.shell("logpersist.start", ignore_status=True)
+        if hasattr(self, "adb_logcat_param"):
+            extra_params = self.adb_logcat_param
+        else:
+            extra_params = "-b all"
+
+        self.adb_logcat_process = logcat.create_logcat_keepalive_process(
+            self.serial, self.log_dir, extra_params
+        )
+        self.adb_logcat_process.start()
+
+    def stop_adb_logcat(self):
+        """Stops the adb logcat collection subprocess."""
+        if not self.is_adb_logcat_on:
+            self.log.warning(
+                f"Android device {self.serial} does not have an ongoing adb logcat "
+            )
+            return
+        # Set the last timestamp to the current timestamp. This may cause
+        # a race condition that allows the same line to be logged twice,
+        # but it does not pose a problem for our logging purposes.
+        self.adb_logcat_process.stop()
+        self.adb_logcat_process = None
+
+    def get_apk_uid(self, apk_name):
+        """Get the uid of the given apk.
+
+        Args:
+        apk_name: Name of the package, e.g., com.android.phone.
+
+        Returns:
+        Linux UID for the apk.
+        """
+        output = self.adb.shell(
+            f"dumpsys package {apk_name} | grep userId=", ignore_status=True
+        )
+        result = re.search(r"userId=(\d+)", output)
+        if result:
+            return result.group(1)
+        else:
+            None
+
+    def get_apk_version(self, package_name):
+        """Get the version of the given apk.
+
+        Args:
+            package_name: Name of the package, e.g., com.android.phone.
+
+        Returns:
+            Version of the given apk.
+        """
+        try:
+            output = self.adb.shell(
+                f"dumpsys package {package_name} | grep versionName"
+            )
+            pattern = re.compile(r"versionName=(.+)", re.I)
+            result = pattern.findall(output)
+            if result:
+                return result[0]
+        except Exception as e:
+            self.log.warning(
+                "Fail to get the version of package %s: %s", package_name, e
+            )
+        self.log.debug("apk %s is not found", package_name)
+        return None
+
+    def is_apk_installed(self, package_name):
+        """Check if the given apk is already installed.
+
+        Args:
+        package_name: Name of the package, e.g., com.android.phone.
+
+        Returns:
+        True if package is installed. False otherwise.
+        """
+
+        try:
+            return bool(
+                self.adb.shell(
+                    f'(pm list packages | grep -w "package:{package_name}") || true'
+                )
+            )
+
+        except Exception as err:
+            self.log.error(
+                "Could not determine if %s is installed. " "Received error:\n%s",
+                package_name,
+                err,
+            )
+            return False
+
+    def is_sl4a_installed(self):
+        return self.is_apk_installed(SL4A_APK_NAME)
+
+    def is_apk_running(self, package_name):
+        """Check if the given apk is running.
+
+        Args:
+            package_name: Name of the package, e.g., com.android.phone.
+
+        Returns:
+        True if package is installed. False otherwise.
+        """
+        for cmd in ("ps -A", "ps"):
+            try:
+                out = self.adb.shell(
+                    f'{cmd} | grep "S {package_name}"', ignore_status=True
+                )
+                if package_name in out:
+                    self.log.info("apk %s is running", package_name)
+                    return True
+            except Exception as e:
+                self.log.warning(
+                    "Device fails to check is %s running by %s " "Exception %s",
+                    package_name,
+                    cmd,
+                    e,
+                )
+                continue
+        self.log.debug("apk %s is not running", package_name)
+        return False
+
+    def is_sl4a_running(self):
+        return self.is_apk_running(SL4A_APK_NAME)
+
+    def force_stop_apk(self, package_name):
+        """Force stop the given apk.
+
+        Args:
+        package_name: Name of the package, e.g., com.android.phone.
+
+        Returns:
+        True if package is installed. False otherwise.
+        """
+        try:
+            self.adb.shell(f"am force-stop {package_name}", ignore_status=True)
+        except Exception as e:
+            self.log.warning("Fail to stop package %s: %s", package_name, e)
+
+    def take_bug_report(self, test_name=None, begin_time=None):
+        """Takes a bug report on the device and stores it in a file.
+
+        Args:
+            test_name: Name of the test case that triggered this bug report.
+            begin_time: Epoch time when the test started. If none is specified,
+                the current time will be used.
+        """
+        self.adb.wait_for_device(timeout=WAIT_FOR_DEVICE_TIMEOUT)
+        new_br = True
+        try:
+            stdout = self.adb.shell("bugreportz -v")
+            # This check is necessary for builds before N, where adb shell's ret
+            # code and stderr are not propagated properly.
+            if "not found" in stdout:
+                new_br = False
+        except AdbError:
+            new_br = False
+        br_path = self.device_log_path
+        os.makedirs(br_path, exist_ok=True)
+        epoch = begin_time if begin_time else utils.get_current_epoch_time()
+        time_stamp = acts_logger.normalize_log_line_timestamp(
+            acts_logger.epoch_to_log_line_timestamp(epoch)
+        )
+        out_name = f"AndroidDevice{self.serial}_{time_stamp}"
+        out_name = f"{out_name}.zip" if new_br else f"{out_name}.txt"
+        full_out_path = os.path.join(br_path, out_name)
+        # in case device restarted, wait for adb interface to return
+        self.wait_for_boot_completion()
+        if test_name:
+            self.log.info("Taking bugreport for %s.", test_name)
+        else:
+            self.log.info("Taking bugreport.")
+        if new_br:
+            out = self.adb.shell("bugreportz", timeout=BUG_REPORT_TIMEOUT)
+            if not out.startswith("OK"):
+                raise errors.AndroidDeviceError(
+                    f"Failed to take bugreport on {self.serial}: {out}",
+                    serial=self.serial,
+                )
+            br_out_path = out.split(":")[1].strip().split()[0]
+            self.adb.pull(f"{br_out_path} {full_out_path}")
+        else:
+            self.adb.bugreport(f" > {full_out_path}", timeout=BUG_REPORT_TIMEOUT)
+        if test_name:
+            self.log.info("Bugreport for %s taken at %s.", test_name, full_out_path)
+        else:
+            self.log.info("Bugreport taken at %s.", test_name, full_out_path)
+        self.adb.wait_for_device(timeout=WAIT_FOR_DEVICE_TIMEOUT)
+
+    def get_file_names(
+        self, directory, begin_time=None, skip_files=[], match_string=None
+    ):
+        """Get files names with provided directory."""
+        cmd = f"find {directory} -type f"
+        if begin_time:
+            current_time = utils.get_current_epoch_time()
+            seconds = int(math.ceil((current_time - begin_time) / 1000.0))
+            cmd = f"{cmd} -mtime -{seconds}s"
+        if match_string:
+            cmd = f"{cmd} -iname {match_string}"
+        for skip_file in skip_files:
+            cmd = f"{cmd} ! -iname {skip_file}"
+        out = self.adb.shell(cmd, ignore_status=True)
+        if (
+            not out
+            or "No such" in out
+            or "Permission denied" in out
+            or "Not a directory" in out
+        ):
+            return []
+        files = out.split("\n")
+        self.log.debug("Find files in directory %s: %s", directory, files)
+        return files
+
+    @property
+    def external_storage_path(self):
+        """
+        The $EXTERNAL_STORAGE path on the device. Most commonly set to '/sdcard'
+        """
+        return self.adb.shell("echo $EXTERNAL_STORAGE")
+
+    def file_exists(self, file_path):
+        """Returns whether a file exists on a device.
+
+        Args:
+            file_path: The path of the file to check for.
+        """
+        cmd = f"(test -f {file_path} && echo yes) || echo no"
+        result = self.adb.shell(cmd)
+        if result == "yes":
+            return True
+        elif result == "no":
+            return False
+        raise ValueError(
+            "Couldn't determine if %s exists. "
+            "Expected yes/no, got %s" % (file_path, result[cmd])
+        )
+
+    def pull_files(self, device_paths, host_path=None):
+        """Pull files from devices.
+
+        Args:
+            device_paths: List of paths on the device to pull from.
+            host_path: Destination path
+        """
+        if isinstance(device_paths, str):
+            device_paths = [device_paths]
+        if not host_path:
+            host_path = self.log_path
+        for device_path in device_paths:
+            self.log.info(f"Pull from device: {device_path} -> {host_path}")
+            self.adb.pull(f"{device_path} {host_path}", timeout=PULL_TIMEOUT)
+
+    def check_crash_report(
+        self, test_name=None, begin_time=None, log_crash_report=False
+    ):
+        """check crash report on the device."""
+        crash_reports = []
+        for crash_path in CRASH_REPORT_PATHS:
+            try:
+                cmd = f"cd {crash_path}"
+                self.adb.shell(cmd)
+            except Exception as e:
+                self.log.debug("received exception %s", e)
+                continue
+            crashes = self.get_file_names(
+                crash_path, skip_files=CRASH_REPORT_SKIPS, begin_time=begin_time
+            )
+            if crash_path == "/data/tombstones/" and crashes:
+                tombstones = crashes[:]
+                for tombstone in tombstones:
+                    if self.adb.shell(
+                        f'cat {tombstone} | grep "crash_dump failed to dump process"'
+                    ):
+                        crashes.remove(tombstone)
+            if crashes:
+                crash_reports.extend(crashes)
+        if crash_reports and log_crash_report:
+            crash_log_path = os.path.join(
+                self.device_log_path, f"Crashes_{self.serial}"
+            )
+            os.makedirs(crash_log_path, exist_ok=True)
+            self.pull_files(crash_reports, crash_log_path)
+        return crash_reports
+
+    def get_qxdm_logs(self, test_name="", begin_time=None):
+        """Get qxdm logs."""
+        # Sleep 10 seconds for the buffered log to be written in qxdm log file
+        time.sleep(10)
+        log_path = getattr(self, "qxdm_log_path", DEFAULT_QXDM_LOG_PATH)
+        qxdm_logs = self.get_file_names(
+            log_path, begin_time=begin_time, match_string="*.qmdl"
+        )
+        if qxdm_logs:
+            qxdm_log_path = os.path.join(self.device_log_path, f"QXDM_{self.serial}")
+            os.makedirs(qxdm_log_path, exist_ok=True)
+
+            self.log.info("Pull QXDM Log %s to %s", qxdm_logs, qxdm_log_path)
+            self.pull_files(qxdm_logs, qxdm_log_path)
+
+            self.adb.pull(
+                f"/firmware/image/qdsp6m.qdb {qxdm_log_path}",
+                timeout=PULL_TIMEOUT,
+                ignore_status=True,
+            )
+            # Zip Folder
+            utils.zip_directory(f"{qxdm_log_path}.zip", qxdm_log_path)
+            shutil.rmtree(qxdm_log_path)
+        else:
+            self.log.error(f"Didn't find QXDM logs in {log_path}.")
+        if "Verizon" in self.adb.getprop("gsm.sim.operator.alpha"):
+            omadm_log_path = os.path.join(self.device_log_path, f"OMADM_{self.serial}")
+            os.makedirs(omadm_log_path, exist_ok=True)
+            self.log.info("Pull OMADM Log")
+            self.adb.pull(
+                f"/data/data/com.android.omadm.service/files/dm/log/ {omadm_log_path}",
+                timeout=PULL_TIMEOUT,
+                ignore_status=True,
+            )
+
+    def get_sdm_logs(self, test_name="", begin_time=None):
+        """Get sdm logs."""
+        # Sleep 10 seconds for the buffered log to be written in sdm log file
+        time.sleep(10)
+        log_paths = [
+            ALWAYS_ON_LOG_PATH,
+            getattr(self, "sdm_log_path", DEFAULT_SDM_LOG_PATH),
+        ]
+        sdm_logs = []
+        for path in log_paths:
+            sdm_logs += self.get_file_names(
+                path, begin_time=begin_time, match_string="*.sdm*"
+            )
+        if sdm_logs:
+            sdm_log_path = os.path.join(self.device_log_path, f"SDM_{self.serial}")
+            os.makedirs(sdm_log_path, exist_ok=True)
+            self.log.info("Pull SDM Log %s to %s", sdm_logs, sdm_log_path)
+            self.pull_files(sdm_logs, sdm_log_path)
+        else:
+            self.log.error(f"Didn't find SDM logs in {log_paths}.")
+        if "Verizon" in self.adb.getprop("gsm.sim.operator.alpha"):
+            omadm_log_path = os.path.join(self.device_log_path, f"OMADM_{self.serial}")
+            os.makedirs(omadm_log_path, exist_ok=True)
+            self.log.info("Pull OMADM Log")
+            self.adb.pull(
+                f"/data/data/com.android.omadm.service/files/dm/log/ {omadm_log_path}",
+                timeout=PULL_TIMEOUT,
+                ignore_status=True,
+            )
+
+    def start_new_session(self, max_connections=None, server_port=None):
+        """Start a new session in sl4a.
+
+        Also caches the droid in a dict with its uid being the key.
+
+        Returns:
+            An Android object used to communicate with sl4a on the android
+                device.
+
+        Raises:
+            Sl4aException: Something is wrong with sl4a and it returned an
+            existing uid to a new session.
+        """
+        session = self._sl4a_manager.create_session(
+            max_connections=max_connections, server_port=server_port
+        )
+
+        self._sl4a_manager.sessions[session.uid] = session
+        return session.rpc_client
+
+    def terminate_all_sessions(self):
+        """Terminate all sl4a sessions on the AndroidDevice instance.
+
+        Terminate all sessions and clear caches.
+        """
+        self._sl4a_manager.terminate_all_sessions()
+
+    def run_iperf_client_nb(
+        self, server_host, extra_args="", timeout=IPERF_TIMEOUT, log_file_path=None
+    ):
+        """Start iperf client on the device asynchronously.
+
+        Return status as true if iperf client start successfully.
+        And data flow information as results.
+
+        Args:
+            server_host: Address of the iperf server.
+            extra_args: A string representing extra arguments for iperf client,
+                e.g. "-i 1 -t 30".
+            log_file_path: The complete file path to log the results.
+
+        """
+        cmd = f"iperf3 -c {server_host} {extra_args}"
+        if log_file_path:
+            cmd += f" --logfile {log_file_path} &"
+        self.adb.shell_nb(cmd)
+
+    def run_iperf_client(self, server_host, extra_args="", timeout=IPERF_TIMEOUT):
+        """Start iperf client on the device.
+
+        Return status as true if iperf client start successfully.
+        And data flow information as results.
+
+        Args:
+            server_host: Address of the iperf server.
+            extra_args: A string representing extra arguments for iperf client,
+                e.g. "-i 1 -t 30".
+
+        Returns:
+            status: true if iperf client start successfully.
+            results: results have data flow information
+        """
+        out = self.adb.shell(f"iperf3 -c {server_host} {extra_args}", timeout=timeout)
+        clean_out = out.split("\n")
+        if "error" in clean_out[0].lower():
+            return False, clean_out
+        return True, clean_out
+
+    def run_iperf_server(self, extra_args=""):
+        """Start iperf server on the device
+
+        Return status as true if iperf server started successfully.
+
+        Args:
+            extra_args: A string representing extra arguments for iperf server.
+
+        Returns:
+            status: true if iperf server started successfully.
+            results: results have output of command
+        """
+        out = self.adb.shell(f"iperf3 -s {extra_args}")
+        clean_out = out.split("\n")
+        if "error" in clean_out[0].lower():
+            return False, clean_out
+        return True, clean_out
+
+    def wait_for_boot_completion(self, timeout=900.0):
+        """Waits for Android framework to broadcast ACTION_BOOT_COMPLETED.
+
+        Args:
+            timeout: Seconds to wait for the device to boot. Default value is
+            15 minutes.
+        """
+        timeout_start = time.time()
+
+        self.log.debug("ADB waiting for device")
+        self.adb.wait_for_device(timeout=timeout)
+        self.log.debug("Waiting for  sys.boot_completed")
+        while time.time() < timeout_start + timeout:
+            try:
+                completed = self.adb.getprop("sys.boot_completed")
+                if completed == "1":
+                    self.log.debug("Device has rebooted")
+                    return
+            except AdbError:
+                # adb shell calls may fail during certain period of booting
+                # process, which is normal. Ignoring these errors.
+                pass
+            time.sleep(5)
+        raise errors.AndroidDeviceError(
+            f"Device {self.serial} booting process timed out.", serial=self.serial
+        )
+
+    def reboot(
+        self, stop_at_lock_screen=False, timeout=180, wait_after_reboot_complete=1
+    ):
+        """Reboots the device.
+
+        Terminate all sl4a sessions, reboot the device, wait for device to
+        complete booting, and restart an sl4a session if restart_sl4a is True.
+
+        Args:
+            stop_at_lock_screen: whether to unlock after reboot. Set to False
+                if want to bring the device to reboot up to password locking
+                phase. Sl4a checking need the device unlocked after rebooting.
+            timeout: time in seconds to wait for the device to complete
+                rebooting.
+            wait_after_reboot_complete: time in seconds to wait after the boot
+                completion.
+        """
+        if self.is_bootloader:
+            self.fastboot.reboot()
+            return
+        self.stop_services()
+        self.log.info("Rebooting")
+        self.adb.reboot()
+
+        timeout_start = time.time()
+        # b/111791239: Newer versions of android sometimes return early after
+        # `adb reboot` is called. This means subsequent calls may make it to
+        # the device before the reboot goes through, return false positives for
+        # getprops such as sys.boot_completed.
+        while time.time() < timeout_start + timeout:
+            try:
+                self.adb.get_state()
+                time.sleep(0.1)
+            except AdbError:
+                # get_state will raise an error if the device is not found. We
+                # want the device to be missing to prove the device has kicked
+                # off the reboot.
+                break
+        self.wait_for_boot_completion(timeout=(timeout - time.time() + timeout_start))
+
+        self.log.debug("Wait for a while after boot completion.")
+        time.sleep(wait_after_reboot_complete)
+        self.root_adb()
+        skip_sl4a = self.skip_sl4a
+        self.skip_sl4a = self.skip_sl4a or stop_at_lock_screen
+        self.start_services()
+        self.skip_sl4a = skip_sl4a
+
+    def restart_runtime(self):
+        """Restarts android runtime.
+
+        Terminate all sl4a sessions, restarts runtime, wait for framework
+        complete restart, and restart an sl4a session if restart_sl4a is True.
+        """
+        self.stop_services()
+        self.log.info("Restarting android runtime")
+        self.adb.shell("stop")
+        # Reset the boot completed flag before we restart the framework
+        # to correctly detect when the framework has fully come up.
+        self.adb.shell("setprop sys.boot_completed 0")
+        self.adb.shell("start")
+        self.wait_for_boot_completion()
+        self.root_adb()
+
+        self.start_services()
+
+    def get_ipv4_address(self, interface="wlan0", timeout=5):
+        for timer in range(0, timeout):
+            try:
+                ip_string = self.adb.shell(f"ifconfig {interface}|grep inet")
+                break
+            except adb.AdbError as e:
+                if timer + 1 == timeout:
+                    self.log.warning(f"Unable to find IP address for {interface}.")
+                    return None
+                else:
+                    time.sleep(1)
+        result = re.search("addr:(.*) Bcast", ip_string)
+        if result != None:
+            ip_address = result.group(1)
+            try:
+                socket.inet_aton(ip_address)
+                return ip_address
+            except socket.error:
+                return None
+        else:
+            return None
+
+    def get_ipv4_gateway(self, timeout=5):
+        for timer in range(0, timeout):
+            try:
+                gateway_string = self.adb.shell("dumpsys wifi | grep mDhcpResults")
+                break
+            except adb.AdbError as e:
+                if timer + 1 == timeout:
+                    self.log.warning("Unable to find gateway")
+                    return None
+                else:
+                    time.sleep(1)
+        result = re.search("Gateway (.*) DNS servers", gateway_string)
+        if result != None:
+            ipv4_gateway = result.group(1)
+            try:
+                socket.inet_aton(ipv4_gateway)
+                return ipv4_gateway
+            except socket.error:
+                return None
+        else:
+            return None
+
+    def send_keycode(self, keycode):
+        self.adb.shell(f"input keyevent KEYCODE_{keycode}")
+
+    def get_my_current_focus_window(self):
+        """Get the current focus window on screen"""
+        output = self.adb.shell(
+            "dumpsys window displays | grep -E mCurrentFocus | grep -v null",
+            ignore_status=True,
+        )
+        if not output or "not found" in output or "Can't find" in output:
+            result = ""
+        else:
+            result = output.split(" ")[-1].strip("}")
+        self.log.debug("Current focus window is %s", result)
+        return result
+
+    def get_my_current_focus_app(self):
+        """Get the current focus application"""
+        dumpsys_cmd = [
+            "dumpsys window | grep -E mFocusedApp",
+            "dumpsys window displays | grep -E mFocusedApp",
+        ]
+        for cmd in dumpsys_cmd:
+            output = self.adb.shell(cmd, ignore_status=True)
+            if (
+                not output
+                or "not found" in output
+                or "Can't find" in output
+                or ("mFocusedApp=null" in output)
+            ):
+                result = ""
+            else:
+                result = output.split(" ")[-2]
+                break
+        self.log.debug("Current focus app is %s", result)
+        return result
+
+    def is_window_ready(self, window_name=None):
+        current_window = self.get_my_current_focus_window()
+        if window_name:
+            return window_name in current_window
+        return current_window and ENCRYPTION_WINDOW not in current_window
+
+    def wait_for_window_ready(
+        self, window_name=None, check_interval=5, check_duration=60
+    ):
+        elapsed_time = 0
+        while elapsed_time < check_duration:
+            if self.is_window_ready(window_name=window_name):
+                return True
+            time.sleep(check_interval)
+            elapsed_time += check_interval
+        self.log.info("Current focus window is %s", self.get_my_current_focus_window())
+        return False
+
+    def is_user_setup_complete(self):
+        return "1" in self.adb.shell("settings get secure user_setup_complete")
+
+    def is_screen_awake(self):
+        """Check if device screen is in sleep mode"""
+        return "Awake" in self.adb.shell("dumpsys power | grep mWakefulness=")
+
+    def is_screen_emergency_dialer(self):
+        """Check if device screen is in emergency dialer mode"""
+        return "EmergencyDialer" in self.get_my_current_focus_window()
+
+    def is_screen_in_call_activity(self):
+        """Check if device screen is in in-call activity notification"""
+        return "InCallActivity" in self.get_my_current_focus_window()
+
+    def is_setupwizard_on(self):
+        """Check if device screen is in emergency dialer mode"""
+        return "setupwizard" in self.get_my_current_focus_app()
+
+    def is_screen_lock_enabled(self):
+        """Check if screen lock is enabled"""
+        cmd = "dumpsys window policy | grep showing="
+        out = self.adb.shell(cmd, ignore_status=True)
+        return "true" in out
+
+    def is_waiting_for_unlock_pin(self):
+        """Check if device is waiting for unlock pin to boot up"""
+        current_window = self.get_my_current_focus_window()
+        current_app = self.get_my_current_focus_app()
+        if ENCRYPTION_WINDOW in current_window:
+            self.log.info("Device is in CrpytKeeper window")
+            return True
+        if "StatusBar" in current_window and (
+            (not current_app) or "FallbackHome" in current_app
+        ):
+            self.log.info("Device is locked")
+            return True
+        return False
+
+    def ensure_screen_on(self):
+        """Ensure device screen is powered on"""
+        if self.is_screen_lock_enabled():
+            for _ in range(2):
+                self.unlock_screen()
+                time.sleep(1)
+                if self.is_waiting_for_unlock_pin():
+                    self.unlock_screen(password=DEFAULT_DEVICE_PASSWORD)
+                    time.sleep(1)
+                if (
+                    not self.is_waiting_for_unlock_pin()
+                    and self.wait_for_window_ready()
+                ):
+                    return True
+            return False
+        else:
+            self.wakeup_screen()
+            return True
+
+    def wakeup_screen(self):
+        if not self.is_screen_awake():
+            self.log.info("Screen is not awake, wake it up")
+            self.send_keycode("WAKEUP")
+
+    def go_to_sleep(self):
+        if self.is_screen_awake():
+            self.send_keycode("SLEEP")
+
+    def send_keycode_number_pad(self, number):
+        self.send_keycode(f"NUMPAD_{number}")
+
+    def unlock_screen(self, password=None):
+        self.log.info("Unlocking with %s", password or "swipe up")
+        # Bring device to SLEEP so that unlock process can start fresh
+        self.send_keycode("SLEEP")
+        time.sleep(1)
+        self.send_keycode("WAKEUP")
+        if ENCRYPTION_WINDOW not in self.get_my_current_focus_app():
+            self.send_keycode("MENU")
+        if password:
+            self.send_keycode("DEL")
+            for number in password:
+                self.send_keycode_number_pad(number)
+            self.send_keycode("ENTER")
+            self.send_keycode("BACK")
+
+    def screenshot(self, name=""):
+        """Take a screenshot on the device.
+
+        Args:
+            name: additional information of screenshot on the file name.
+        """
+        if name:
+            file_name = f"{DEFAULT_SCREENSHOT_PATH}_{name}"
+        file_name = f"{file_name}_{utils.get_current_epoch_time()}.png"
+        self.ensure_screen_on()
+        self.log.info("Log screenshot to %s", file_name)
+        try:
+            self.adb.shell(f"screencap -p {file_name}")
+        except:
+            self.log.error("Fail to log screenshot to %s", file_name)
+
+    def exit_setup_wizard(self):
+        # Handling Android TV's setupwizard is ignored for now.
+        if "feature:android.hardware.type.television" in self.adb.shell(
+            "pm list features"
+        ):
+            return
+        if not self.is_user_setup_complete() or self.is_setupwizard_on():
+            # b/116709539 need this to prevent reboot after skip setup wizard
+            self.adb.shell(
+                "am start -a com.android.setupwizard.EXIT", ignore_status=True
+            )
+            self.adb.shell(
+                f"pm disable {self.get_setupwizard_package_name()}",
+                ignore_status=True,
+            )
+        # Wait up to 5 seconds for user_setup_complete to be updated
+        end_time = time.time() + 5
+        while time.time() < end_time:
+            if self.is_user_setup_complete() or not self.is_setupwizard_on():
+                return
+
+        # If fail to exit setup wizard, set local.prop and reboot
+        if not self.is_user_setup_complete() and self.is_setupwizard_on():
+            self.adb.shell("echo ro.test_harness=1 > /data/local.prop")
+            self.adb.shell("chmod 644 /data/local.prop")
+            self.reboot(stop_at_lock_screen=True)
+
+    def get_setupwizard_package_name(self):
+        """Finds setupwizard package/.activity
+
+        Bypass setupwizard or setupwraith depending on device.
+
+         Returns:
+            packageName/.ActivityName
+        """
+        packages_to_skip = "'setupwizard|setupwraith'"
+        android_package_name = "com.google.android"
+        package = self.adb.shell(
+            "pm list packages -f | grep -E {} | grep {}".format(
+                packages_to_skip, android_package_name
+            )
+        )
+        wizard_package = package.split("=")[1]
+        activity = package.split("=")[0].split("/")[-2]
+        self.log.info(f"{wizard_package}/.{activity}Activity")
+        return f"{wizard_package}/.{activity}Activity"
+
+    def push_system_file(self, src_file_path, dst_file_path, push_timeout=300):
+        """Pushes a file onto the read-only file system.
+
+        For speed, the device is left in root mode after this call, and leaves
+        verity disabled. To re-enable verity, call ensure_verity_enabled().
+
+        Args:
+            src_file_path: The path to the system app to install.
+            dst_file_path: The destination of the file.
+            push_timeout: How long to wait for the push to finish.
+        Returns:
+            Whether or not the install was successful.
+        """
+        self.adb.ensure_root()
+        try:
+            self.ensure_verity_disabled()
+            self.adb.remount()
+            out = self.adb.push(
+                f"{src_file_path} {dst_file_path}", timeout=push_timeout
+            )
+            if "error" in out:
+                self.log.error(
+                    "Unable to push system file %s to %s due to %s",
+                    src_file_path,
+                    dst_file_path,
+                    out,
+                )
+                return False
+            return True
+        except Exception as e:
+            self.log.error(
+                "Unable to push system file %s to %s due to %s",
+                src_file_path,
+                dst_file_path,
+                e,
+            )
+            return False
+
+    def ensure_verity_enabled(self):
+        """Ensures that verity is enabled.
+
+        If verity is not enabled, this call will reboot the phone. Note that
+        this only works on debuggable builds.
+        """
+        user = self.adb.get_user_id()
+        # The below properties will only exist if verity has been enabled.
+        system_verity = self.adb.getprop("partition.system.verified")
+        vendor_verity = self.adb.getprop("partition.vendor.verified")
+        if not system_verity or not vendor_verity:
+            self.adb.ensure_root()
+            self.adb.enable_verity()
+            self.reboot()
+            self.adb.ensure_user(user)
+
+    def ensure_verity_disabled(self):
+        """Ensures that verity is disabled.
+
+        If verity is enabled, this call will reboot the phone.
+        """
+        user = self.adb.get_user_id()
+        # The below properties will only exist if verity has been enabled.
+        system_verity = self.adb.getprop("partition.system.verified")
+        vendor_verity = self.adb.getprop("partition.vendor.verified")
+        if system_verity or vendor_verity:
+            self.adb.ensure_root()
+            self.adb.disable_verity()
+            self.reboot()
+            self.adb.ensure_user(user)
+
+
+class AndroidDeviceLoggerAdapter(logging.LoggerAdapter):
+    def process(self, msg, kwargs):
+        msg = f"[AndroidDevice|{self.extra['serial']}] {msg}"
+        return (msg, kwargs)
diff --git a/src/antlion/controllers/android_lib/__init__.py b/packages/antlion/controllers/android_lib/__init__.py
similarity index 100%
rename from src/antlion/controllers/android_lib/__init__.py
rename to packages/antlion/controllers/android_lib/__init__.py
diff --git a/src/antlion/controllers/android_lib/errors.py b/packages/antlion/controllers/android_lib/errors.py
similarity index 100%
rename from src/antlion/controllers/android_lib/errors.py
rename to packages/antlion/controllers/android_lib/errors.py
diff --git a/src/antlion/controllers/android_lib/events.py b/packages/antlion/controllers/android_lib/events.py
similarity index 100%
rename from src/antlion/controllers/android_lib/events.py
rename to packages/antlion/controllers/android_lib/events.py
diff --git a/packages/antlion/controllers/android_lib/logcat.py b/packages/antlion/controllers/android_lib/logcat.py
new file mode 100644
index 0000000..4aab7d0
--- /dev/null
+++ b/packages/antlion/controllers/android_lib/logcat.py
@@ -0,0 +1,104 @@
+#!/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 logging
+import re
+
+from antlion.libs.logging import log_stream
+from antlion.libs.logging.log_stream import LogStyles
+from antlion.libs.proc.process import Process
+
+TIMESTAMP_REGEX = r"((?:\d+-)?\d+-\d+ \d+:\d+:\d+.\d+)"
+
+
+class TimestampTracker(object):
+    """Stores the last timestamp outputted by the Logcat process."""
+
+    def __init__(self):
+        self._last_timestamp = None
+
+    @property
+    def last_timestamp(self):
+        return self._last_timestamp
+
+    def read_output(self, message):
+        """Reads the message and parses all timestamps from it."""
+        all_timestamps = re.findall(TIMESTAMP_REGEX, message)
+        if len(all_timestamps) > 0:
+            self._last_timestamp = all_timestamps[0]
+
+
+def _get_log_level(message):
+    """Returns the log level for the given message."""
+    if message.startswith("-") or len(message) < 37:
+        return logging.ERROR
+    else:
+        log_level = message[36]
+        if log_level in ("V", "D"):
+            return logging.DEBUG
+        elif log_level == "I":
+            return logging.INFO
+        elif log_level == "W":
+            return logging.WARNING
+        elif log_level == "E":
+            return logging.ERROR
+    return logging.NOTSET
+
+
+def _log_line_func(log, timestamp_tracker):
+    """Returns a lambda that logs a message to the given logger."""
+
+    def log_line(message):
+        timestamp_tracker.read_output(message)
+        log.log(_get_log_level(message), message)
+
+    return log_line
+
+
+def _on_retry(serial, extra_params, timestamp_tracker):
+    def on_retry(_):
+        begin_at = '"%s"' % (timestamp_tracker.last_timestamp or 1)
+        additional_params = extra_params or ""
+
+        return f"adb -s {serial} logcat -T {begin_at} -v year {additional_params}"
+
+    return on_retry
+
+
+def create_logcat_keepalive_process(serial, logcat_dir, extra_params=""):
+    """Creates a Logcat Process that automatically attempts to reconnect.
+
+    Args:
+        serial: The serial of the device to read the logcat of.
+        logcat_dir: The directory used for logcat file output.
+        extra_params: Any additional params to be added to the logcat cmdline.
+
+    Returns:
+        A acts.libs.proc.process.Process object.
+    """
+    logger = log_stream.create_logger(
+        f"adblog_{serial}",
+        log_name=serial,
+        subcontext=logcat_dir,
+        log_styles=(LogStyles.LOG_DEBUG | LogStyles.TESTCASE_LOG),
+    )
+    process = Process(f"adb -s {serial} logcat -T 1 -v year {extra_params}")
+    timestamp_tracker = TimestampTracker()
+    process.set_on_output_callback(_log_line_func(logger, timestamp_tracker))
+    process.set_on_terminate_callback(
+        _on_retry(serial, extra_params, timestamp_tracker)
+    )
+    return process
diff --git a/packages/antlion/controllers/android_lib/services.py b/packages/antlion/controllers/android_lib/services.py
new file mode 100644
index 0000000..098f524
--- /dev/null
+++ b/packages/antlion/controllers/android_lib/services.py
@@ -0,0 +1,118 @@
+#!/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.
+
+from antlion.controllers.android_lib import errors
+from antlion.controllers.android_lib import events as android_events
+from antlion.event import event_bus
+
+
+class AndroidService(object):
+    """The base class for Android long-running services.
+
+    The _start method is registered to an AndroidStartServicesEvent, and
+    the _stop method is registered to an AndroidStopServicesEvent.
+
+    Attributes:
+        ad: The AndroidDevice instance associated with the service.
+        serial: The serial of the device.
+        _registration_ids: List of registration IDs for the event subscriptions.
+    """
+
+    def __init__(self, ad):
+        self.ad = ad
+        self._registration_ids = []
+
+    @property
+    def serial(self):
+        return self.ad.serial
+
+    def register(self):
+        """Registers the _start and _stop methods to their corresponding
+        events.
+        """
+
+        def check_serial(event):
+            return self.serial == event.ad.serial
+
+        self._registration_ids = [
+            event_bus.register(
+                android_events.AndroidStartServicesEvent,
+                self._start,
+                filter_fn=check_serial,
+            ),
+            event_bus.register(
+                android_events.AndroidStopServicesEvent,
+                self._stop,
+                filter_fn=check_serial,
+            ),
+        ]
+
+    def unregister(self):
+        """Unregisters all subscriptions in this service."""
+        event_bus.unregister_all(from_list=self._registration_ids)
+        self._registration_ids.clear()
+
+    def _start(self, start_event):
+        """Start the service. Called upon an AndroidStartServicesEvent.
+
+        Args:
+            start_event: The AndroidStartServicesEvent instance.
+        """
+        raise NotImplementedError
+
+    def _stop(self, stop_event):
+        """Stop the service. Called upon an AndroidStopServicesEvent.
+
+        Args:
+            stop_event: The AndroidStopServicesEvent instance.
+        """
+        raise NotImplementedError
+
+
+class AdbLogcatService(AndroidService):
+    """Service for adb logcat."""
+
+    def _start(self, _):
+        self.ad.start_adb_logcat()
+
+    def _stop(self, _):
+        self.ad.stop_adb_logcat()
+
+
+class Sl4aService(AndroidService):
+    """Service for SL4A."""
+
+    def _start(self, start_event):
+        if self.ad.skip_sl4a:
+            return
+
+        if not self.ad.is_sl4a_installed():
+            self.ad.log.error("sl4a.apk is not installed")
+            raise errors.AndroidDeviceError(
+                "The required sl4a.apk is not installed", serial=self.serial
+            )
+        if not self.ad.ensure_screen_on():
+            self.ad.log.error("User window cannot come up")
+            raise errors.AndroidDeviceError(
+                "User window cannot come up", serial=self.serial
+            )
+
+        droid, ed = self.ad.get_droid()
+        ed.start()
+
+    def _stop(self, _):
+        self.ad.terminate_all_sessions()
+        self.ad._sl4a_manager.stop_service()
diff --git a/src/antlion/controllers/ap_lib/__init__.py b/packages/antlion/controllers/ap_lib/__init__.py
similarity index 100%
rename from src/antlion/controllers/ap_lib/__init__.py
rename to packages/antlion/controllers/ap_lib/__init__.py
diff --git a/packages/antlion/controllers/ap_lib/ap_get_interface.py b/packages/antlion/controllers/ap_lib/ap_get_interface.py
new file mode 100644
index 0000000..9028ded
--- /dev/null
+++ b/packages/antlion/controllers/ap_lib/ap_get_interface.py
@@ -0,0 +1,192 @@
+#!/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 logging
+from typing import TYPE_CHECKING
+
+from antlion.runner import CalledProcessError
+
+if TYPE_CHECKING:
+    from antlion.controllers.access_point import AccessPoint
+
+GET_ALL_INTERFACE = "ls /sys/class/net"
+GET_VIRTUAL_INTERFACE = "ls /sys/devices/virtual/net"
+BRCTL_SHOW = "brctl show"
+
+
+class ApInterfacesError(Exception):
+    """Error related to AP interfaces."""
+
+
+class ApInterfaces(object):
+    """Class to get network interface information for the device."""
+
+    def __init__(
+        self, ap: "AccessPoint", wan_interface_override: str | None = None
+    ) -> None:
+        """Initialize the ApInterface class.
+
+        Args:
+            ap: the ap object within ACTS
+            wan_interface_override: wan interface to use if specified by config
+        """
+        self.ssh = ap.ssh
+        self.wan_interface_override = wan_interface_override
+
+    def get_all_interface(self) -> list[str]:
+        """Get all network interfaces on the device.
+
+        Returns:
+            interfaces_all: list of all the network interfaces on device
+        """
+        output = self.ssh.run(GET_ALL_INTERFACE)
+        interfaces_all = output.stdout.decode("utf-8").split("\n")
+
+        return interfaces_all
+
+    def get_virtual_interface(self) -> list[str]:
+        """Get all virtual interfaces on the device.
+
+        Returns:
+            interfaces_virtual: list of all the virtual interfaces on device
+        """
+        output = self.ssh.run(GET_VIRTUAL_INTERFACE)
+        interfaces_virtual = output.stdout.decode("utf-8").split("\n")
+
+        return interfaces_virtual
+
+    def get_physical_interface(self) -> list[str]:
+        """Get all the physical interfaces of the device.
+
+        Get all physical interfaces such as eth ports and wlan ports
+
+        Returns:
+            interfaces_phy: list of all the physical interfaces
+        """
+        interfaces_all = self.get_all_interface()
+        interfaces_virtual = self.get_virtual_interface()
+        interfaces_phy = list(set(interfaces_all) - set(interfaces_virtual))
+
+        return interfaces_phy
+
+    def get_bridge_interface(self) -> list[str]:
+        """Get all the bridge interfaces of the device.
+
+        Returns:
+            interfaces_bridge: the list of bridge interfaces, return None if
+                bridge utility is not available on the device
+
+        Raises:
+            ApInterfaceError: Failing to run brctl
+        """
+        try:
+            output = self.ssh.run(BRCTL_SHOW)
+        except CalledProcessError as e:
+            raise ApInterfacesError(f'failed to execute "{BRCTL_SHOW}"') from e
+
+        lines = output.stdout.decode("utf-8").split("\n")
+        interfaces_bridge = []
+        for line in lines:
+            interfaces_bridge.append(line.split("\t")[0])
+        interfaces_bridge.pop(0)
+        return [x for x in interfaces_bridge if x != ""]
+
+    def get_wlan_interface(self) -> tuple[str, str]:
+        """Get all WLAN interfaces and specify 2.4 GHz and 5 GHz interfaces.
+
+        Returns:
+            interfaces_wlan: all wlan interfaces
+        Raises:
+            ApInterfacesError: Missing at least one WLAN interface
+        """
+        wlan_2g = None
+        wlan_5g = None
+        interfaces_phy = self.get_physical_interface()
+        for iface in interfaces_phy:
+            output = self.ssh.run(f"iwlist {iface} freq")
+            if b"Channel 06" in output.stdout and b"Channel 36" not in output.stdout:
+                wlan_2g = iface
+            elif b"Channel 36" in output.stdout and b"Channel 06" not in output.stdout:
+                wlan_5g = iface
+
+        if wlan_2g is None or wlan_5g is None:
+            raise ApInterfacesError("Missing at least one WLAN interface")
+
+        return (wlan_2g, wlan_5g)
+
+    def get_wan_interface(self) -> str:
+        """Get the WAN interface which has internet connectivity. If a wan
+        interface is already specified return that instead.
+
+        Returns:
+            wan: the only one WAN interface
+        Raises:
+            ApInterfacesError: no running WAN can be found
+        """
+        if self.wan_interface_override:
+            return self.wan_interface_override
+
+        wan = None
+        interfaces_phy = self.get_physical_interface()
+        interfaces_wlan = self.get_wlan_interface()
+        interfaces_eth = list(set(interfaces_phy) - set(interfaces_wlan))
+        for iface in interfaces_eth:
+            network_status = self.check_ping(iface)
+            if network_status == 1:
+                wan = iface
+                break
+        if wan:
+            return wan
+
+        output = self.ssh.run("ifconfig")
+        interfaces_all = output.stdout.decode("utf-8").split("\n")
+        logging.info(f"IFCONFIG output = {interfaces_all}")
+
+        raise ApInterfacesError("No WAN interface available")
+
+    def get_lan_interface(self) -> str | None:
+        """Get the LAN interface connecting to local devices.
+
+        Returns:
+            lan: the only one running LAN interface of the devices
+            None, if nothing was found.
+        """
+        lan = None
+        interfaces_phy = self.get_physical_interface()
+        interfaces_wlan = self.get_wlan_interface()
+        interfaces_eth = list(set(interfaces_phy) - set(interfaces_wlan))
+        interface_wan = self.get_wan_interface()
+        interfaces_eth.remove(interface_wan)
+        for iface in interfaces_eth:
+            output = self.ssh.run(f"ifconfig {iface}")
+            if b"RUNNING" in output.stdout:
+                lan = iface
+                break
+        return lan
+
+    def check_ping(self, iface: str) -> int:
+        """Check the ping status on specific interface to determine the WAN.
+
+        Args:
+            iface: the specific interface to check
+        Returns:
+            network_status: the connectivity status of the interface
+        """
+        try:
+            self.ssh.run(f"ping -c 3 -I {iface} 8.8.8.8")
+            return 1
+        except CalledProcessError:
+            return 0
diff --git a/packages/antlion/controllers/ap_lib/ap_iwconfig.py b/packages/antlion/controllers/ap_lib/ap_iwconfig.py
new file mode 100644
index 0000000..d5b4556
--- /dev/null
+++ b/packages/antlion/controllers/ap_lib/ap_iwconfig.py
@@ -0,0 +1,49 @@
+#!/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 subprocess
+from typing import TYPE_CHECKING
+
+if TYPE_CHECKING:
+    from antlion.controllers.access_point import AccessPoint
+
+
+class ApIwconfigError(Exception):
+    """Error related to configuring the wireless interface via iwconfig."""
+
+
+class ApIwconfig(object):
+    """Class to configure wireless interface via iwconfig"""
+
+    PROGRAM_FILE = "/usr/local/sbin/iwconfig"
+
+    def __init__(self, ap: "AccessPoint") -> None:
+        """Initialize the ApIwconfig class.
+
+        Args:
+            ap: the ap object within ACTS
+        """
+        self.ssh = ap.ssh
+
+    def ap_iwconfig(
+        self, interface: str, arguments: str | None = None
+    ) -> subprocess.CompletedProcess[bytes]:
+        """Configure the wireless interface using iwconfig.
+
+        Returns:
+            output: the output of the command, if any
+        """
+        return self.ssh.run(f"{self.PROGRAM_FILE} {interface} {arguments}")
diff --git a/packages/antlion/controllers/ap_lib/bridge_interface.py b/packages/antlion/controllers/ap_lib/bridge_interface.py
new file mode 100644
index 0000000..383d289
--- /dev/null
+++ b/packages/antlion/controllers/ap_lib/bridge_interface.py
@@ -0,0 +1,116 @@
+#!/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 logging
+import time
+
+from antlion.runner import CalledProcessError
+
+_BRCTL = "brctl"
+BRIDGE_NAME = "br-lan"
+CREATE_BRIDGE = f"{_BRCTL} addbr {BRIDGE_NAME}"
+DELETE_BRIDGE = f"{_BRCTL} delbr {BRIDGE_NAME}"
+BRING_DOWN_BRIDGE = f"ifconfig {BRIDGE_NAME} down"
+
+
+class BridgeInterfaceConfigs(object):
+    """Configs needed for creating bridge interface between LAN and WLAN."""
+
+    def __init__(self, iface_wlan, iface_lan, bridge_ip):
+        """Set bridge interface configs based on the channel info.
+
+        Args:
+            iface_wlan: the wlan interface as part of the bridge
+            iface_lan: the ethernet LAN interface as part of the bridge
+            bridge_ip: the ip address assigned to the bridge interface
+        """
+        self.iface_wlan = iface_wlan
+        self.iface_lan = iface_lan
+        self.bridge_ip = bridge_ip
+
+
+class BridgeInterface(object):
+    """Class object for bridge interface betwen WLAN and LAN"""
+
+    def __init__(self, ap):
+        """Initialize the BridgeInterface class.
+
+        Bridge interface will be added between ethernet LAN port and WLAN port.
+        Args:
+            ap: AP object within ACTS
+        """
+        self.ssh = ap.ssh
+
+    def startup(self, brconfigs):
+        """Start up the bridge interface.
+
+        Args:
+            brconfigs: the bridge interface config, type BridgeInterfaceConfigs
+        """
+
+        logging.info("Create bridge interface between LAN and WLAN")
+        # Create the bridge
+        try:
+            self.ssh.run(CREATE_BRIDGE)
+        except CalledProcessError:
+            logging.warning(
+                f"Bridge interface {BRIDGE_NAME} already exists, no action needed"
+            )
+
+        # Enable 4addr mode on for the wlan interface
+        ENABLE_4ADDR = f"iw dev {brconfigs.iface_wlan} set 4addr on"
+        try:
+            self.ssh.run(ENABLE_4ADDR)
+        except CalledProcessError:
+            logging.warning(f"4addr is already enabled on {brconfigs.iface_wlan}")
+
+        # Add both LAN and WLAN interfaces to the bridge interface
+        for interface in [brconfigs.iface_lan, brconfigs.iface_wlan]:
+            ADD_INTERFACE = f"{_BRCTL} addif {BRIDGE_NAME} {interface}"
+            try:
+                self.ssh.run(ADD_INTERFACE)
+            except CalledProcessError:
+                logging.warning(f"{interface} has already been added to {BRIDGE_NAME}")
+        time.sleep(5)
+
+        # Set IP address on the bridge interface to bring it up
+        SET_BRIDGE_IP = f"ifconfig {BRIDGE_NAME} {brconfigs.bridge_ip}"
+        self.ssh.run(SET_BRIDGE_IP)
+        time.sleep(2)
+
+        # Bridge interface is up
+        logging.info("Bridge interface is up and running")
+
+    def teardown(self, brconfigs):
+        """Tear down the bridge interface.
+
+        Args:
+            brconfigs: the bridge interface config, type BridgeInterfaceConfigs
+        """
+        logging.info("Bringing down the bridge interface")
+        # Delete the bridge interface
+        self.ssh.run(BRING_DOWN_BRIDGE)
+        time.sleep(1)
+        self.ssh.run(DELETE_BRIDGE)
+
+        # Bring down wlan interface and disable 4addr mode
+        BRING_DOWN_WLAN = f"ifconfig {brconfigs.iface_wlan} down"
+        self.ssh.run(BRING_DOWN_WLAN)
+        time.sleep(2)
+        DISABLE_4ADDR = f"iw dev {brconfigs.iface_wlan} set 4addr off"
+        self.ssh.run(DISABLE_4ADDR)
+        time.sleep(1)
+        logging.info("Bridge interface is down")
diff --git a/packages/antlion/controllers/ap_lib/dhcp_config.py b/packages/antlion/controllers/ap_lib/dhcp_config.py
new file mode 100644
index 0000000..5fa8cf0
--- /dev/null
+++ b/packages/antlion/controllers/ap_lib/dhcp_config.py
@@ -0,0 +1,205 @@
+# 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 copy
+from ipaddress import IPv4Address, IPv4Network
+
+_ROUTER_DNS = "8.8.8.8, 4.4.4.4"
+
+
+class Subnet(object):
+    """Configs for a subnet  on the dhcp server.
+
+    Attributes:
+        network: ipaddress.IPv4Network, the network that this subnet is in.
+        start: ipaddress.IPv4Address, the start ip address.
+        end: ipaddress.IPv4Address, the end ip address.
+        router: The router to give to all hosts in this subnet.
+        lease_time: The lease time of all hosts in this subnet.
+        additional_parameters: A dictionary corresponding to DHCP parameters.
+        additional_options: A dictionary corresponding to DHCP options.
+    """
+
+    def __init__(
+        self,
+        subnet: IPv4Network,
+        start: IPv4Address | None = None,
+        end: IPv4Address | None = None,
+        router: IPv4Address | None = None,
+        lease_time: int | None = None,
+        additional_parameters: dict[str, str] = {},
+        additional_options: dict[str, int | str] = {},
+    ):
+        """
+        Args:
+            subnet: ipaddress.IPv4Network, The address space of the subnetwork
+                    served by the DHCP server.
+            start: ipaddress.IPv4Address, The start of the address range to
+                   give hosts in this subnet. If not given, the second ip in
+                   the network is used, under the assumption that the first
+                   address is the router.
+            end: ipaddress.IPv4Address, The end of the address range to give
+                 hosts. If not given then the address prior to the broadcast
+                 address (i.e. the second to last ip in the network) is used.
+            router: ipaddress.IPv4Address, The router hosts should use in this
+                    subnet. If not given the first ip in the network is used.
+            lease_time: int, The amount of lease time in seconds
+                        hosts in this subnet have.
+            additional_parameters: A dictionary corresponding to DHCP parameters.
+            additional_options: A dictionary corresponding to DHCP options.
+        """
+        self.network = subnet
+
+        if start:
+            self.start = start
+        else:
+            self.start = self.network[2]
+
+        if not self.start in self.network:
+            raise ValueError("The start range is not in the subnet.")
+        if self.start.is_reserved:
+            raise ValueError("The start of the range cannot be reserved.")
+
+        if end:
+            self.end = end
+        else:
+            self.end = self.network[-2]
+
+        if not self.end in self.network:
+            raise ValueError("The end range is not in the subnet.")
+        if self.end.is_reserved:
+            raise ValueError("The end of the range cannot be reserved.")
+        if self.end < self.start:
+            raise ValueError("The end must be an address larger than the start.")
+
+        if router:
+            if router >= self.start and router <= self.end:
+                raise ValueError("Router must not be in pool range.")
+            if not router in self.network:
+                raise ValueError("Router must be in the given subnet.")
+
+            self.router = router
+        else:
+            # TODO: Use some more clever logic so that we don't have to search
+            # every host potentially.
+            # This is especially important if we support IPv6 networks in this
+            # configuration. The improved logic that we can use is:
+            #    a) erroring out if start and end encompass the whole network, and
+            #    b) picking any address before self.start or after self.end.
+            for host in self.network.hosts():
+                if host < self.start or host > self.end:
+                    self.router = host
+                    break
+
+            if not hasattr(self, "router"):
+                raise ValueError("No useable host found.")
+
+        self.lease_time = lease_time
+        self.additional_parameters = additional_parameters
+        self.additional_options = additional_options
+        if "domain-name-servers" not in self.additional_options:
+            self.additional_options["domain-name-servers"] = _ROUTER_DNS
+
+
+class StaticMapping(object):
+    """Represents a static dhcp host.
+
+    Attributes:
+        identifier: How id of the host (usually the mac addres
+                    e.g. 00:11:22:33:44:55).
+        address: ipaddress.IPv4Address, The ipv4 address to give the host.
+        lease_time: How long to give a lease to this host.
+    """
+
+    def __init__(self, identifier, address, lease_time=None):
+        self.identifier = identifier
+        self.ipv4_address = address
+        self.lease_time = lease_time
+
+
+class DhcpConfig(object):
+    """The configs for a dhcp server.
+
+    Attributes:
+        subnets: A list of all subnets for the dhcp server to create.
+        static_mappings: A list of static host addresses.
+        default_lease_time: The default time for a lease.
+        max_lease_time: The max time to allow a lease.
+    """
+
+    def __init__(
+        self,
+        subnets=None,
+        static_mappings=None,
+        default_lease_time=600,
+        max_lease_time=7200,
+    ):
+        self.subnets = copy.deepcopy(subnets) if subnets else []
+        self.static_mappings = copy.deepcopy(static_mappings) if static_mappings else []
+        self.default_lease_time = default_lease_time
+        self.max_lease_time = max_lease_time
+
+    def render_config_file(self):
+        """Renders the config parameters into a format compatible with
+        the ISC DHCP server (dhcpd).
+        """
+        lines = []
+
+        if self.default_lease_time:
+            lines.append(f"default-lease-time {self.default_lease_time};")
+        if self.max_lease_time:
+            lines.append(f"max-lease-time {self.max_lease_time};")
+
+        for subnet in self.subnets:
+            address = subnet.network.network_address
+            mask = subnet.network.netmask
+            router = subnet.router
+            start = subnet.start
+            end = subnet.end
+            lease_time = subnet.lease_time
+            additional_parameters = subnet.additional_parameters
+            additional_options = subnet.additional_options
+
+            lines.append("subnet %s netmask %s {" % (address, mask))
+            lines.append("\tpool {")
+            lines.append(f"\t\toption subnet-mask {mask};")
+            lines.append(f"\t\toption routers {router};")
+            lines.append(f"\t\trange {start} {end};")
+            if lease_time:
+                lines.append(f"\t\tdefault-lease-time {lease_time};")
+                lines.append(f"\t\tmax-lease-time {lease_time};")
+            for param, value in additional_parameters.items():
+                lines.append(f"\t\t{param} {value};")
+            for option, value in additional_options.items():
+                lines.append(f"\t\toption {option} {value};")
+            lines.append("\t}")
+            lines.append("}")
+
+        for mapping in self.static_mappings:
+            identifier = mapping.identifier
+            fixed_address = mapping.ipv4_address
+            host_fake_name = f"host{identifier.replace(':', '')}"
+            lease_time = mapping.lease_time
+
+            lines.append("host %s {" % host_fake_name)
+            lines.append(f"\thardware ethernet {identifier};")
+            lines.append(f"\tfixed-address {fixed_address};")
+            if lease_time:
+                lines.append(f"\tdefault-lease-time {lease_time};")
+                lines.append(f"\tmax-lease-time {lease_time};")
+            lines.append("}")
+
+        config_str = "\n".join(lines)
+
+        return config_str
diff --git a/packages/antlion/controllers/ap_lib/dhcp_server.py b/packages/antlion/controllers/ap_lib/dhcp_server.py
new file mode 100644
index 0000000..dd3f608
--- /dev/null
+++ b/packages/antlion/controllers/ap_lib/dhcp_server.py
@@ -0,0 +1,212 @@
+# 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 logging
+import time
+
+from mobly import logger
+from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_fixed
+
+from antlion.controllers.ap_lib.dhcp_config import DhcpConfig
+from antlion.controllers.utils_lib.commands import shell
+from antlion.runner import Runner
+
+
+class Error(Exception):
+    """An error caused by the dhcp server."""
+
+
+class NoInterfaceError(Exception):
+    """Error thrown when the dhcp server has no interfaces on any subnet."""
+
+
+class DhcpServer(object):
+    """Manages the dhcp server program.
+
+    Only one of these can run in an environment at a time.
+
+    Attributes:
+        config: The dhcp server configuration that is being used.
+    """
+
+    PROGRAM_FILE = "dhcpd"
+
+    def __init__(self, runner: Runner, interface: str, working_dir: str = "/tmp"):
+        """
+        Args:
+            runner: Object that has a run_async and run methods for running
+                    shell commands.
+            interface: string, The name of the interface to use.
+            working_dir: The directory to work out of.
+        """
+        self._log = logger.PrefixLoggerAdapter(
+            logging.getLogger(),
+            {
+                logger.PrefixLoggerAdapter.EXTRA_KEY_LOG_PREFIX: f"[DHCP Server|{interface}]",
+            },
+        )
+
+        self._runner = runner
+        self._working_dir = working_dir
+        self._shell = shell.ShellCommand(runner)
+        self._stdio_log_file = f"{working_dir}/dhcpd_{interface}.log"
+        self._config_file = f"{working_dir}/dhcpd_{interface}.conf"
+        self._lease_file = f"{working_dir}/dhcpd_{interface}.leases"
+        self._pid_file = f"{working_dir}/dhcpd_{interface}.pid"
+        self._identifier: int | None = None
+
+    # There is a slight timing issue where if the proc filesystem in Linux
+    # doesn't get updated in time as when this is called, the NoInterfaceError
+    # will happening.  By adding this retry, the error appears to have gone away
+    # but will still show a warning if the problem occurs.  The error seems to
+    # happen more with bridge interfaces than standard interfaces.
+    @retry(
+        retry=retry_if_exception_type(NoInterfaceError),
+        stop=stop_after_attempt(3),
+        wait=wait_fixed(1),
+    )
+    def start(self, config: DhcpConfig, timeout_sec: int = 60) -> None:
+        """Starts the dhcp server.
+
+        Starts the dhcp server daemon and runs it in the background.
+
+        Args:
+            config: Configs to start the dhcp server with.
+
+        Raises:
+            Error: Raised when a dhcp server error is found.
+        """
+        if self.is_alive():
+            self.stop()
+
+        self._write_configs(config)
+        self._shell.delete_file(self._stdio_log_file)
+        self._shell.delete_file(self._pid_file)
+        self._shell.touch_file(self._lease_file)
+
+        dhcpd_command = (
+            f"{self.PROGRAM_FILE} "
+            f'-cf "{self._config_file}" '
+            f"-lf {self._lease_file} "
+            f'-pf "{self._pid_file}" '
+            "-f -d"
+        )
+
+        base_command = f'cd "{self._working_dir}"; {dhcpd_command}'
+        job_str = f'{base_command} > "{self._stdio_log_file}" 2>&1'
+        self._identifier = int(self._runner.run_async(job_str).stdout)
+
+        try:
+            self._wait_for_process(timeout=timeout_sec)
+            self._wait_for_server(timeout=timeout_sec)
+        except:
+            self._log.warning("Failed to start DHCP server.")
+            self._log.info(f"DHCP configuration:\n{config.render_config_file()}\n")
+            self._log.info(f"DHCP logs:\n{self.get_logs()}\n")
+            self.stop()
+            raise
+
+    def stop(self) -> None:
+        """Kills the daemon if it is running."""
+        if self._identifier and self.is_alive():
+            self._shell.kill(self._identifier)
+            self._identifier = None
+
+    def is_alive(self) -> bool:
+        """
+        Returns:
+            True if the daemon is running.
+        """
+        if self._identifier:
+            return self._shell.is_alive(self._identifier)
+        return False
+
+    def get_logs(self) -> str:
+        """Pulls the log files from where dhcp server is running.
+
+        Returns:
+            A string of the dhcp server logs.
+        """
+        return self._shell.read_file(self._stdio_log_file)
+
+    def _wait_for_process(self, timeout: float = 60) -> None:
+        """Waits for the process to come up.
+
+        Waits until the dhcp server process is found running, or there is
+        a timeout. If the program never comes up then the log file
+        will be scanned for errors.
+
+        Raises: See _scan_for_errors
+        """
+        start_time = time.time()
+        while time.time() - start_time < timeout and not self.is_alive():
+            self._scan_for_errors(False)
+            time.sleep(0.1)
+
+        self._scan_for_errors(True)
+
+    def _wait_for_server(self, timeout: float = 60) -> None:
+        """Waits for dhcp server to report that the server is up.
+
+        Waits until dhcp server says the server has been brought up or an
+        error occurs.
+
+        Raises: see _scan_for_errors
+        """
+        start_time = time.time()
+        while time.time() - start_time < timeout:
+            success = self._shell.search_file(
+                "Wrote [0-9]* leases to leases file", self._stdio_log_file
+            )
+            if success:
+                return
+
+            self._scan_for_errors(True)
+
+    def _scan_for_errors(self, should_be_up: bool) -> None:
+        """Scans the dhcp server log for any errors.
+
+        Args:
+            should_be_up: If true then dhcp server is expected to be alive.
+                          If it is found not alive while this is true an error
+                          is thrown.
+
+        Raises:
+            Error: Raised when a dhcp server error is found.
+        """
+        # If this is checked last we can run into a race condition where while
+        # scanning the log the process has not died, but after scanning it
+        # has. If this were checked last in that condition then the wrong
+        # error will be thrown. To prevent this we gather the alive state first
+        # so that if it is dead it will definitely give the right error before
+        # just giving a generic one.
+        is_dead = not self.is_alive()
+
+        no_interface = self._shell.search_file(
+            "Not configured to listen on any interfaces", self._stdio_log_file
+        )
+        if no_interface:
+            raise NoInterfaceError(
+                "Dhcp does not contain a subnet for any of the networks the"
+                " current interfaces are on."
+            )
+
+        if should_be_up and is_dead:
+            raise Error("Dhcp server failed to start.", self)
+
+    def _write_configs(self, config: DhcpConfig) -> None:
+        """Writes the configs to the dhcp server config file."""
+        self._shell.delete_file(self._config_file)
+        config_str = config.render_config_file()
+        self._shell.write_file(self._config_file, config_str)
diff --git a/packages/antlion/controllers/ap_lib/extended_capabilities.py b/packages/antlion/controllers/ap_lib/extended_capabilities.py
new file mode 100644
index 0000000..4570409
--- /dev/null
+++ b/packages/antlion/controllers/ap_lib/extended_capabilities.py
@@ -0,0 +1,193 @@
+#!/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.
+
+from enum import IntEnum, unique
+
+
+@unique
+class ExtendedCapability(IntEnum):
+    """All extended capabilities present in IEEE 802.11-2020 Table 9-153.
+
+    Each name has a value corresponding to that extended capability's bit offset
+    in the specification's extended capabilities field.
+
+    Note that most extended capabilities are represented by a single bit, which
+    indicates whether the extended capability is advertised by the STA; but
+    some are represented by multiple bits. In the enum, each extended capability
+    has the value of its offset; comments indicate capabilities that use
+    multiple bits.
+    """
+
+    TWENTY_FORTY_BSS_COEXISTENCE_MANAGEMENT_SUPPORT = 0
+    GLK = 1
+    EXTENDED_CHANNEL_SWITCHING = 2
+    GLK_GCR = 3
+    PSMP_CAPABILITY = 4
+    # 5 reserved
+    S_PSMP_SUPPORT = 6
+    EVENT = 7
+    DIAGNOSTICS = 8
+    MULTICAST_DIAGNOSTICS = 9
+    LOCATION_TRACKING = 10
+    FMS = 11
+    PROXY_ARP_SERVICE = 12
+    COLLOCATED_INTERFERENCE_REPORTING = 13
+    CIVIC_LOCATION = 14
+    GEOSPATIAL_LOCATION = 15
+    TFS = 16
+    WNM_SLEEP_MODE = 17
+    TIM_BROADCAST = 18
+    BSS_TRANSITION = 19
+    QOS_TRAFFIC_CAPABILITY = 20
+    AC_STATION_COUNT = 21
+    MULTIPLE_BSSID = 22
+    TIMING_MEASUREMENT = 23
+    CHANNEL_USAGE = 24
+    SSID_LIST = 25
+    DMS = 26
+    UTC_TSF_OFFSET = 27
+    TPU_BUFFER_STA_SUPPORT = 28
+    TDLS_PEER_PSM_SUPPORT = 29
+    TDLS_CHANNEL_SWITCHING = 30
+    INTERWORKING = 31
+    QOS_MAP = 32
+    EBR = 33
+    SSPN_INTERFACE = 34
+    # 35 reserved
+    MSGCF_CAPABILITY = 36
+    TDLS_SUPPORT = 37
+    TDLS_PROHIBITED = 38
+    TDLS_CHANNEL_SWITCHING_PROHIBITED = 39
+    REJECT_UNADMITTED_FRAME = 40
+    SERVICE_INTERVAL_GRANULARITY = 41
+    # Bits 41-43 contain SERVICE_INTERVAL_GRANULARITY value
+    IDENTIFIER_LOCATION = 44
+    U_APSD_COEXISTENCE = 45
+    WNM_NOTIFICATION = 46
+    QAB_CAPABILITY = 47
+    UTF_8_SSID = 48
+    QMF_ACTIVATED = 49
+    QMF_RECONFIGURATION_ACTIVATED = 50
+    ROBUST_AV_STREAMING = 51
+    ADVANCED_GCR = 52
+    MESH_GCR = 53
+    SCS = 54
+    QLOAD_REPORT = 55
+    ALTERNATE_EDCA = 56
+    UNPROTECTED_TXOP_NEGOTIATION = 57
+    PROTECTED_TXOP_NEGOTIATION = 58
+    # 59 reserved
+    PROTECTED_QLOAD_REPORT = 60
+    TDLS_WIDER_BANDWIDTH = 61
+    OPERATING_MODE_NOTIFICATION = 62
+    MAX_NUMBER_OF_MSDUS_IN_A_MSDU = 63
+    # 63-64 contain MAX_NUMBER_OF_MSDUS_IN_A_MSDU value
+    CHANNEL_SCHEDULE_MANAGEMENT = 65
+    GEODATABASE_INBAND_ENABLING_SIGNAL = 66
+    NETWORK_CHANNEL_CONTROL = 67
+    WHITE_SPACE_MAP = 68
+    CHANNEL_AVAILABILITY_QUERY = 69
+    FINE_TIMING_MEASUREMENT_RESPONDER = 70
+    FINE_TIMING_MEASUREMENT_INITIATOR = 71
+    FILS_CAPABILITY = 72
+    EXTENDED_SPECTRUM_MANAGEMENT_CAPABLE = 73
+    FUTURE_CHANNEL_GUIDANCE = 74
+    PAD = 75
+    # 76-79 reserved
+    COMPLETE_LIST_OF_NON_TX_BSSID_PROFILES = 80
+    SAE_PASSWORD_IDENTIFIERS_IN_USE = 81
+    SAE_PASSWORD_IDENTIFIERS_USED_EXCLUSIVELY = 82
+    # 83 reserved
+    BEACON_PROTECTION_ENABLED = 84
+    MIRRORED_SCS = 85
+    # 86 reserved
+    LOCAL_MAC_ADDRESS_POLICY = 87
+    # 88-n reserved
+
+
+def _offsets(ext_cap_offset: ExtendedCapability) -> tuple[int, int]:
+    """For given capability, return the byte and bit offsets within the field.
+
+    802.11 divides the extended capability field into bytes, as does the
+    ExtendedCapabilities class below. This function returns the index of the
+    byte that contains the given extended capability, as well as the bit offset
+    inside that byte (all offsets zero-indexed). For example,
+    MULTICAST_DIAGNOSTICS is bit 9, which is within byte 1 at bit offset 1.
+    """
+    byte_offset = ext_cap_offset // 8
+    bit_offset = ext_cap_offset % 8
+    return byte_offset, bit_offset
+
+
+class ExtendedCapabilities:
+    """Extended capability parsing and representation.
+
+    See IEEE 802.11-2020 9.4.2.26.
+    """
+
+    def __init__(self, ext_cap: bytearray = bytearray()):
+        """Represent the given extended capabilities field.
+
+        Args:
+            ext_cap: IEEE 802.11-2020 9.4.2.26 extended capabilities field.
+            Default is an empty field, meaning no extended capabilities are
+            advertised.
+        """
+        self._ext_cap = ext_cap
+
+    def _capability_advertised(self, ext_cap: ExtendedCapability) -> bool:
+        """Whether an extended capability is advertised.
+
+        Args:
+            ext_cap: an extended capability.
+        Returns:
+            True if the bit is present and its value is 1, otherwise False.
+        Raises:
+            NotImplementedError: for extended capabilities that span more than
+            a single bit. These could be supported, but no callers need them
+            at this time.
+        """
+        if ext_cap in [
+            ExtendedCapability.SERVICE_INTERVAL_GRANULARITY,
+            ExtendedCapability.MAX_NUMBER_OF_MSDUS_IN_A_MSDU,
+        ]:
+            raise NotImplementedError(
+                f"{ext_cap.name} not implemented yet by {self.__class__}"
+            )
+        byte_offset, bit_offset = _offsets(ext_cap)
+        if len(self._ext_cap) > byte_offset:
+            # Use bit_offset to derive a mask that will check the correct bit.
+            if self._ext_cap[byte_offset] & 2**bit_offset > 0:
+                return True
+        return False
+
+    @property
+    def bss_transition(self) -> bool:
+        return self._capability_advertised(ExtendedCapability.BSS_TRANSITION)
+
+    @property
+    def proxy_arp_service(self) -> bool:
+        return self._capability_advertised(ExtendedCapability.PROXY_ARP_SERVICE)
+
+    @property
+    def utc_tsf_offset(self) -> bool:
+        return self._capability_advertised(ExtendedCapability.UTC_TSF_OFFSET)
+
+    @property
+    def wnm_sleep_mode(self) -> bool:
+        return self._capability_advertised(ExtendedCapability.WNM_SLEEP_MODE)
+
+    # Other extended capability property methods can be added as needed by callers.
diff --git a/packages/antlion/controllers/ap_lib/hostapd.py b/packages/antlion/controllers/ap_lib/hostapd.py
new file mode 100644
index 0000000..87a0bb2
--- /dev/null
+++ b/packages/antlion/controllers/ap_lib/hostapd.py
@@ -0,0 +1,442 @@
+# 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 collections
+import itertools
+import logging
+import re
+import time
+from datetime import datetime, timezone
+from subprocess import CalledProcessError
+from typing import Any, Iterable
+
+from antlion.controllers.ap_lib import hostapd_constants
+from antlion.controllers.ap_lib.extended_capabilities import ExtendedCapabilities
+from antlion.controllers.ap_lib.hostapd_config import HostapdConfig
+from antlion.controllers.ap_lib.wireless_network_management import (
+    BssTransitionManagementRequest,
+)
+from antlion.controllers.utils_lib.commands import shell
+from antlion.logger import LogLevel
+from antlion.runner import Runner
+
+PROGRAM_FILE = "/usr/sbin/hostapd"
+CLI_PROGRAM_FILE = "/usr/bin/hostapd_cli"
+
+
+class Error(Exception):
+    """An error caused by hostapd."""
+
+
+class Hostapd(object):
+    """Manages the hostapd program.
+
+    Attributes:
+        config: The hostapd configuration that is being used.
+    """
+
+    def __init__(
+        self, runner: Runner, interface: str, working_dir: str = "/tmp"
+    ) -> None:
+        """
+        Args:
+            runner: Object that has run_async and run methods for executing
+                    shell commands (e.g. connection.SshConnection)
+            interface: The name of the interface to use (eg. wlan0).
+            working_dir: The directory to work out of.
+        """
+        self._runner = runner
+        self._interface = interface
+        self._working_dir = working_dir
+        self.config: HostapdConfig | None = None
+        self._shell = shell.ShellCommand(runner)
+        self._log_file = f"{working_dir}/hostapd-{self._interface}.log"
+        self._ctrl_file = f"{working_dir}/hostapd-{self._interface}.ctrl"
+        self._config_file = f"{working_dir}/hostapd-{self._interface}.conf"
+        self._identifier = f"{PROGRAM_FILE}.*{self._config_file}"
+
+    def start(
+        self,
+        config: HostapdConfig,
+        timeout: int = 60,
+        additional_parameters: dict[str, Any] | None = None,
+    ) -> None:
+        """Starts hostapd
+
+        Starts the hostapd daemon and runs it in the background.
+
+        Args:
+            config: Configs to start the hostapd with.
+            timeout: Time to wait for DHCP server to come up.
+            additional_parameters: A dictionary of parameters that can sent
+                                   directly into the hostapd config file.  This
+                                   can be used for debugging and or adding one
+                                   off parameters into the config.
+
+        Returns:
+            True if the daemon could be started. Note that the daemon can still
+            start and not work. Invalid configurations can take a long amount
+            of time to be produced, and because the daemon runs indefinitely
+            it's impossible to wait on. If you need to check if configs are ok
+            then periodic checks to is_running and logs should be used.
+        """
+        if additional_parameters is None:
+            additional_parameters = {}
+
+        self.stop()
+
+        self.config = config
+
+        self._shell.delete_file(self._ctrl_file)
+        self._shell.delete_file(self._log_file)
+        self._shell.delete_file(self._config_file)
+        self._write_configs(additional_parameters)
+
+        hostapd_command = f'{PROGRAM_FILE} -dd -t "{self._config_file}"'
+        base_command = f'cd "{self._working_dir}"; {hostapd_command}'
+        job_str = f'rfkill unblock all; {base_command} > "{self._log_file}" 2>&1'
+        self._runner.run_async(job_str)
+
+        try:
+            self._wait_for_process(timeout=timeout)
+            self._wait_for_interface(timeout=timeout)
+        except:
+            self.stop()
+            raise
+
+    def stop(self) -> None:
+        """Kills the daemon if it is running."""
+        if self.is_alive():
+            self._shell.kill(self._identifier)
+
+    def channel_switch(self, channel_num: int) -> None:
+        """Switches to the given channel.
+
+        Returns:
+            acts.libs.proc.job.Result containing the results of the command.
+        Raises: See _run_hostapd_cli_cmd
+        """
+        try:
+            channel_freq = hostapd_constants.FREQUENCY_MAP[channel_num]
+        except KeyError:
+            raise ValueError(f"Invalid channel number {channel_num}")
+        csa_beacon_count = 10
+        channel_switch_cmd = f"chan_switch {csa_beacon_count} {channel_freq}"
+        self._run_hostapd_cli_cmd(channel_switch_cmd)
+
+    def get_current_channel(self) -> int:
+        """Returns the current channel number.
+
+        Raises: See _run_hostapd_cli_cmd
+        """
+        status_cmd = "status"
+        result = self._run_hostapd_cli_cmd(status_cmd)
+        match = re.search(r"^channel=(\d+)$", result, re.MULTILINE)
+        if not match:
+            raise Error("Current channel could not be determined")
+        try:
+            channel = int(match.group(1))
+        except ValueError:
+            raise Error("Internal error: current channel could not be parsed")
+        return channel
+
+    def get_stas(self) -> set[str]:
+        """Return MAC addresses of all associated STAs."""
+        list_sta_result = self._run_hostapd_cli_cmd("list_sta")
+        stas = set()
+        for line in list_sta_result.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:
+                stas.add(m.group(1))
+        return stas
+
+    def _sta(self, sta_mac: str) -> str:
+        """Return hostapd's detailed info about an associated STA.
+
+        Returns:
+            Results of the command.
+
+        Raises: See _run_hostapd_cli_cmd
+        """
+        return self._run_hostapd_cli_cmd(f"sta {sta_mac}")
+
+    def get_sta_extended_capabilities(self, sta_mac: str) -> ExtendedCapabilities:
+        """Get extended capabilities for the given STA, as seen by the AP.
+
+        Args:
+            sta_mac: MAC address of the STA in question.
+        Returns:
+            Extended capabilities of the given STA.
+        Raises:
+            Error if extended capabilities for the STA cannot be obtained.
+        """
+        sta_result = self._sta(sta_mac)
+        # hostapd ext_capab field is a hex encoded string representation of the
+        # 802.11 extended capabilities structure, each byte represented by two
+        # chars (each byte having format %02x).
+        m = re.search(r"ext_capab=([0-9A-Faf]+)", sta_result, re.MULTILINE)
+        if not m:
+            raise Error("Failed to get ext_capab from STA details")
+        raw_ext_capab = m.group(1)
+        try:
+            return ExtendedCapabilities(bytearray.fromhex(raw_ext_capab))
+        except ValueError:
+            raise Error(f"ext_capab contains invalid hex string repr {raw_ext_capab}")
+
+    def sta_authenticated(self, sta_mac: str) -> bool:
+        """Is the given STA authenticated?
+
+        Args:
+            sta_mac: MAC address of the STA in question.
+        Returns:
+            True if AP sees that the STA is authenticated, False otherwise.
+        Raises:
+            Error if authenticated status for the STA cannot be obtained.
+        """
+        sta_result = self._sta(sta_mac)
+        m = re.search(r"flags=.*\[AUTH\]", sta_result, re.MULTILINE)
+        return bool(m)
+
+    def sta_associated(self, sta_mac: str) -> bool:
+        """Is the given STA associated?
+
+        Args:
+            sta_mac: MAC address of the STA in question.
+        Returns:
+            True if AP sees that the STA is associated, False otherwise.
+        Raises:
+            Error if associated status for the STA cannot be obtained.
+        """
+        sta_result = self._sta(sta_mac)
+        m = re.search(r"flags=.*\[ASSOC\]", sta_result, re.MULTILINE)
+        return bool(m)
+
+    def sta_authorized(self, sta_mac: str) -> bool:
+        """Is the given STA authorized (802.1X controlled port open)?
+
+        Args:
+            sta_mac: MAC address of the STA in question.
+        Returns:
+            True if AP sees that the STA is 802.1X authorized, False otherwise.
+        Raises:
+            Error if authorized status for the STA cannot be obtained.
+        """
+        sta_result = self._sta(sta_mac)
+        m = re.search(r"flags=.*\[AUTHORIZED\]", sta_result, re.MULTILINE)
+        return bool(m)
+
+    def _bss_tm_req(
+        self, client_mac: str, request: BssTransitionManagementRequest
+    ) -> None:
+        """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.
+        if request.candidate_list is not None:
+            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}"
+                )
+
+        self._run_hostapd_cli_cmd(bss_tm_req_cmd)
+
+    def send_bss_transition_management_req(
+        self, sta_mac: str, request: BssTransitionManagementRequest
+    ) -> None:
+        """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
+        """
+        self._bss_tm_req(sta_mac, request)
+
+    def is_alive(self) -> bool:
+        """
+        Returns:
+            True if the daemon is running.
+        """
+        return self._shell.is_alive(self._identifier)
+
+    def pull_logs(self) -> str:
+        """Pulls the log files from where hostapd is running.
+
+        Returns:
+            A string of the hostapd logs.
+        """
+        # TODO: Auto pulling of logs when stop is called.
+        with LogLevel(self._runner.log, logging.INFO):
+            log = self._shell.read_file(self._log_file)
+
+        # Convert epoch to human-readable times
+        result: list[str] = []
+        for line in log.splitlines():
+            try:
+                end = line.index(":")
+                epoch = float(line[:end])
+                timestamp = datetime.fromtimestamp(epoch, timezone.utc).strftime(
+                    "%m-%d %H:%M:%S.%f"
+                )
+                result.append(f"{timestamp} {line[end+1:]}")
+            except ValueError:  # Colon not found or float conversion failure
+                result.append(line)
+
+        return "\n".join(result)
+
+    def _run_hostapd_cli_cmd(self, cmd: str) -> str:
+        """Run the given hostapd_cli command.
+
+        Runs the command, waits for the output (up to default timeout), and
+            returns the result.
+
+        Returns:
+            Results of the ssh command.
+
+        Raises:
+            subprocess.TimeoutExpired: When the remote command took too
+                long to execute.
+            antlion.controllers.utils_lib.ssh.connection.Error: When the ssh
+                connection failed to be created.
+            subprocess.CalledProcessError: Ssh worked, but the command had an
+                error executing.
+        """
+        hostapd_cli_job = (
+            f"cd {self._working_dir}; " f"{CLI_PROGRAM_FILE} -p {self._ctrl_file} {cmd}"
+        )
+        proc = self._runner.run(hostapd_cli_job)
+        if proc.returncode:
+            raise CalledProcessError(
+                proc.returncode, hostapd_cli_job, proc.stdout, proc.stderr
+            )
+        return proc.stdout.decode("utf-8")
+
+    def _wait_for_process(self, timeout: int = 60) -> None:
+        """Waits for the process to come up.
+
+        Waits until the hostapd process is found running, or there is
+        a timeout. If the program never comes up then the log file
+        will be scanned for errors.
+
+        Raises: See _scan_for_errors
+        """
+        start_time = time.time()
+        while time.time() - start_time < timeout and not self.is_alive():
+            self._scan_for_errors(False)
+            time.sleep(0.1)
+
+    def _wait_for_interface(self, timeout: int = 60) -> None:
+        """Waits for hostapd to report that the interface is up.
+
+        Waits until hostapd says the interface has been brought up or an
+        error occurs.
+
+        Raises: see _scan_for_errors
+        """
+        start_time = time.time()
+        while time.time() - start_time < timeout:
+            time.sleep(0.1)
+            success = self._shell.search_file("Setup of interface done", self._log_file)
+            if success:
+                return
+            self._scan_for_errors(False)
+
+        self._scan_for_errors(True)
+
+    def _scan_for_errors(self, should_be_up: bool) -> None:
+        """Scans the hostapd log for any errors.
+
+        Args:
+            should_be_up: If true then hostapd program is expected to be alive.
+                          If it is found not alive while this is true an error
+                          is thrown.
+
+        Raises:
+            Error: Raised when a hostapd error is found.
+        """
+        # Store this so that all other errors have priority.
+        is_dead = not self.is_alive()
+
+        bad_config = self._shell.search_file(
+            "Interface initialization failed", self._log_file
+        )
+        if bad_config:
+            raise Error("Interface failed to start", self)
+
+        bad_config = self._shell.search_file(
+            f"Interface {self._interface} wasn't started", self._log_file
+        )
+        if bad_config:
+            raise Error("Interface failed to start", self)
+
+        if should_be_up and is_dead:
+            raise Error("Hostapd failed to start", self)
+
+    def _write_configs(self, additional_parameters: dict[str, Any]) -> None:
+        """Writes the configs to the hostapd config file."""
+        self._shell.delete_file(self._config_file)
+
+        interface_configs = collections.OrderedDict()
+        interface_configs["interface"] = self._interface
+        interface_configs["ctrl_interface"] = self._ctrl_file
+        pairs: Iterable[str] = (f"{k}={v}" for k, v in interface_configs.items())
+
+        packaged_configs = self.config.package_configs() if self.config else []
+        if additional_parameters:
+            packaged_configs.append(additional_parameters)
+        for packaged_config in packaged_configs:
+            config_pairs = (
+                f"{k}={v}" for k, v in packaged_config.items() if v is not None
+            )
+            pairs = itertools.chain(pairs, config_pairs)
+
+        hostapd_conf = "\n".join(pairs)
+
+        logging.info(f"Writing {self._config_file}")
+        logging.debug("******************Start*******************")
+        logging.debug(f"\n{hostapd_conf}")
+        logging.debug("*******************End********************")
+
+        self._shell.write_file(self._config_file, hostapd_conf)
diff --git a/packages/antlion/controllers/ap_lib/hostapd_ap_preset.py b/packages/antlion/controllers/ap_lib/hostapd_ap_preset.py
new file mode 100644
index 0000000..6a11120
--- /dev/null
+++ b/packages/antlion/controllers/ap_lib/hostapd_ap_preset.py
@@ -0,0 +1,544 @@
+# 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.
+
+from __future__ import annotations
+
+from typing import Any, FrozenSet, TypeVar
+
+from antlion.controllers.ap_lib import hostapd_config, hostapd_constants, hostapd_utils
+from antlion.controllers.ap_lib.hostapd_security import Security
+from antlion.controllers.ap_lib.third_party_ap_profiles import (
+    actiontec,
+    asus,
+    belkin,
+    linksys,
+    netgear,
+    securifi,
+    tplink,
+)
+
+T = TypeVar("T")
+
+
+def _get_or_default(var: T | None, default_value: T) -> T:
+    """Check variable and return non-null value.
+
+    Args:
+         var: Any variable.
+         default_value: Value to return if the var is None.
+
+    Returns:
+         Variable value if not None, default value otherwise.
+    """
+    return var if var is not None else default_value
+
+
+def create_ap_preset(
+    iface_wlan_2g: str,
+    iface_wlan_5g: str,
+    profile_name: str = "whirlwind",
+    channel: int | None = None,
+    mode: str | None = None,
+    frequency: int | None = None,
+    security: Security | None = None,
+    pmf_support: int | None = None,
+    ssid: str | None = None,
+    hidden: bool | None = None,
+    dtim_period: int | None = None,
+    frag_threshold: int | None = None,
+    rts_threshold: int | None = None,
+    force_wmm: bool | None = None,
+    beacon_interval: int | None = None,
+    short_preamble: bool | None = None,
+    n_capabilities: list[Any] | None = None,
+    ac_capabilities: list[Any] | None = None,
+    vht_bandwidth: int | None = None,
+    wnm_features: FrozenSet[hostapd_constants.WnmFeature] = frozenset(),
+    bss_settings: list[Any] = [],
+) -> hostapd_config.HostapdConfig:
+    """AP preset config generator.  This a wrapper for hostapd_config but
+       but supplies the default settings for the preset that is selected.
+
+        You may specify channel or frequency, but not both.  Both options
+        are checked for validity (i.e. you can't specify an invalid channel
+        or a frequency that will not be accepted).
+
+    Args:
+        profile_name: The name of the device want the preset for.
+                      Options: whirlwind
+        channel: Channel number.
+        dtim: DTIM value of the AP, default is 2.
+        frequency: Frequency of channel.
+        security: The security settings to use.
+        ssid: The name of the ssid to broadcast.
+        pmf_support: Whether pmf is disabled, enabled, or required
+        vht_bandwidth: VHT bandwidth for 11ac operation.
+        bss_settings: The settings for all bss.
+        iface_wlan_2g: the wlan 2g interface name of the AP.
+        iface_wlan_5g: the wlan 5g interface name of the AP.
+        mode: The hostapd 802.11 mode of operation.
+        ssid: The ssid for the wireless network.
+        hidden: Whether to include the ssid in the beacons.
+        dtim_period: The dtim period for the BSS
+        frag_threshold: Max size of packet before fragmenting the packet.
+        rts_threshold: Max size of packet before requiring protection for
+            rts/cts or cts to self.
+        n_capabilities: 802.11n capabilities for for BSS to advertise.
+        ac_capabilities: 802.11ac capabilities for for BSS to advertise.
+        wnm_features: WNM features to enable on the AP.
+
+    Returns: A hostapd_config object that can be used by the hostapd object.
+    """
+    if security is None:
+        security = Security()
+
+    # Verify interfaces
+    hostapd_utils.verify_interface(iface_wlan_2g, hostapd_constants.INTERFACE_2G_LIST)
+    hostapd_utils.verify_interface(iface_wlan_5g, hostapd_constants.INTERFACE_5G_LIST)
+
+    if channel is not None:
+        frequency = hostapd_config.get_frequency_for_channel(channel)
+    elif frequency is not None:
+        channel = hostapd_config.get_channel_for_frequency(frequency)
+
+    if channel is None or frequency is None:
+        raise ValueError("Must specify channel or frequency")
+
+    if profile_name == "whirlwind":
+        # profile indicates phy mode is 11bgn for 2.4Ghz or 11acn for 5Ghz
+        hidden = _get_or_default(hidden, False)
+        force_wmm = _get_or_default(force_wmm, True)
+        beacon_interval = _get_or_default(beacon_interval, 100)
+        short_preamble = _get_or_default(short_preamble, True)
+        dtim_period = _get_or_default(dtim_period, 2)
+        frag_threshold = _get_or_default(frag_threshold, 2346)
+        rts_threshold = _get_or_default(rts_threshold, 2347)
+        if frequency < 5000:
+            interface = iface_wlan_2g
+            mode = _get_or_default(mode, hostapd_constants.MODE_11N_MIXED)
+            n_capabilities = _get_or_default(
+                n_capabilities,
+                [
+                    hostapd_constants.N_CAPABILITY_LDPC,
+                    hostapd_constants.N_CAPABILITY_SGI20,
+                    hostapd_constants.N_CAPABILITY_SGI40,
+                    hostapd_constants.N_CAPABILITY_TX_STBC,
+                    hostapd_constants.N_CAPABILITY_RX_STBC1,
+                    hostapd_constants.N_CAPABILITY_DSSS_CCK_40,
+                ],
+            )
+            config = hostapd_config.HostapdConfig(
+                ssid=ssid,
+                hidden=hidden,
+                security=security,
+                pmf_support=pmf_support,
+                interface=interface,
+                mode=mode,
+                force_wmm=force_wmm,
+                beacon_interval=beacon_interval,
+                dtim_period=dtim_period,
+                short_preamble=short_preamble,
+                frequency=frequency,
+                n_capabilities=n_capabilities,
+                frag_threshold=frag_threshold,
+                rts_threshold=rts_threshold,
+                wnm_features=wnm_features,
+                bss_settings=bss_settings,
+            )
+        else:
+            interface = iface_wlan_5g
+            vht_bandwidth = _get_or_default(vht_bandwidth, 80)
+            mode = _get_or_default(mode, hostapd_constants.MODE_11AC_MIXED)
+            if hostapd_config.ht40_plus_allowed(channel):
+                extended_channel = hostapd_constants.N_CAPABILITY_HT40_PLUS
+            elif hostapd_config.ht40_minus_allowed(channel):
+                extended_channel = hostapd_constants.N_CAPABILITY_HT40_MINUS
+            # Channel 165 operates in 20MHz with n or ac modes.
+            if channel == 165:
+                mode = hostapd_constants.MODE_11N_MIXED
+                extended_channel = hostapd_constants.N_CAPABILITY_HT20
+            # Define the n capability vector for 20 MHz and higher bandwidth
+            if not vht_bandwidth:
+                n_capabilities = _get_or_default(n_capabilities, [])
+            elif vht_bandwidth >= 40:
+                n_capabilities = _get_or_default(
+                    n_capabilities,
+                    [
+                        hostapd_constants.N_CAPABILITY_LDPC,
+                        extended_channel,
+                        hostapd_constants.N_CAPABILITY_SGI20,
+                        hostapd_constants.N_CAPABILITY_SGI40,
+                        hostapd_constants.N_CAPABILITY_TX_STBC,
+                        hostapd_constants.N_CAPABILITY_RX_STBC1,
+                    ],
+                )
+            else:
+                n_capabilities = _get_or_default(
+                    n_capabilities,
+                    [
+                        hostapd_constants.N_CAPABILITY_LDPC,
+                        hostapd_constants.N_CAPABILITY_SGI20,
+                        hostapd_constants.N_CAPABILITY_SGI40,
+                        hostapd_constants.N_CAPABILITY_TX_STBC,
+                        hostapd_constants.N_CAPABILITY_RX_STBC1,
+                        hostapd_constants.N_CAPABILITY_HT20,
+                    ],
+                )
+            ac_capabilities = _get_or_default(
+                ac_capabilities,
+                [
+                    hostapd_constants.AC_CAPABILITY_MAX_MPDU_11454,
+                    hostapd_constants.AC_CAPABILITY_RXLDPC,
+                    hostapd_constants.AC_CAPABILITY_SHORT_GI_80,
+                    hostapd_constants.AC_CAPABILITY_TX_STBC_2BY1,
+                    hostapd_constants.AC_CAPABILITY_RX_STBC_1,
+                    hostapd_constants.AC_CAPABILITY_MAX_A_MPDU_LEN_EXP7,
+                    hostapd_constants.AC_CAPABILITY_RX_ANTENNA_PATTERN,
+                    hostapd_constants.AC_CAPABILITY_TX_ANTENNA_PATTERN,
+                ],
+            )
+            config = hostapd_config.HostapdConfig(
+                ssid=ssid,
+                hidden=hidden,
+                security=security,
+                pmf_support=pmf_support,
+                interface=interface,
+                mode=mode,
+                force_wmm=force_wmm,
+                vht_channel_width=vht_bandwidth,
+                beacon_interval=beacon_interval,
+                dtim_period=dtim_period,
+                short_preamble=short_preamble,
+                frequency=frequency,
+                frag_threshold=frag_threshold,
+                rts_threshold=rts_threshold,
+                wnm_features=wnm_features,
+                n_capabilities=n_capabilities,
+                ac_capabilities=ac_capabilities,
+                bss_settings=bss_settings,
+            )
+    elif profile_name == "whirlwind_11ab_legacy":
+        if frequency < 5000:
+            mode = hostapd_constants.MODE_11B
+        else:
+            mode = hostapd_constants.MODE_11A
+
+        config = create_ap_preset(
+            iface_wlan_2g=iface_wlan_2g,
+            iface_wlan_5g=iface_wlan_5g,
+            ssid=ssid,
+            channel=channel,
+            mode=mode,
+            security=security,
+            pmf_support=pmf_support,
+            hidden=hidden,
+            force_wmm=force_wmm,
+            beacon_interval=beacon_interval,
+            short_preamble=short_preamble,
+            dtim_period=dtim_period,
+            rts_threshold=rts_threshold,
+            frag_threshold=frag_threshold,
+            n_capabilities=[],
+            ac_capabilities=[],
+            vht_bandwidth=None,
+            wnm_features=wnm_features,
+        )
+    elif profile_name == "whirlwind_11ag_legacy":
+        if frequency < 5000:
+            mode = hostapd_constants.MODE_11G
+        else:
+            mode = hostapd_constants.MODE_11A
+
+        config = create_ap_preset(
+            iface_wlan_2g=iface_wlan_2g,
+            iface_wlan_5g=iface_wlan_5g,
+            ssid=ssid,
+            channel=channel,
+            mode=mode,
+            security=security,
+            pmf_support=pmf_support,
+            hidden=hidden,
+            force_wmm=force_wmm,
+            beacon_interval=beacon_interval,
+            short_preamble=short_preamble,
+            dtim_period=dtim_period,
+            rts_threshold=rts_threshold,
+            frag_threshold=frag_threshold,
+            n_capabilities=[],
+            ac_capabilities=[],
+            vht_bandwidth=None,
+            wnm_features=wnm_features,
+        )
+    elif profile_name == "mistral":
+        hidden = _get_or_default(hidden, False)
+        force_wmm = _get_or_default(force_wmm, True)
+        beacon_interval = _get_or_default(beacon_interval, 100)
+        short_preamble = _get_or_default(short_preamble, True)
+        dtim_period = _get_or_default(dtim_period, 2)
+        frag_threshold = None
+        rts_threshold = None
+
+        # Google IE
+        # Country Code IE ('us' lowercase)
+        vendor_elements = {
+            "vendor_elements": "dd0cf4f5e80505ff0000ffffffff" "070a75732024041e95051e00"
+        }
+        default_configs = {"bridge": "br-lan", "iapp_interface": "br-lan"}
+        additional_params = (
+            vendor_elements
+            | default_configs
+            | hostapd_constants.ENABLE_RRM_BEACON_REPORT
+            | hostapd_constants.ENABLE_RRM_NEIGHBOR_REPORT
+        )
+
+        if frequency < 5000:
+            interface = iface_wlan_2g
+            mode = _get_or_default(mode, hostapd_constants.MODE_11N_MIXED)
+            n_capabilities = _get_or_default(
+                n_capabilities,
+                [
+                    hostapd_constants.N_CAPABILITY_LDPC,
+                    hostapd_constants.N_CAPABILITY_SGI20,
+                    hostapd_constants.N_CAPABILITY_SGI40,
+                    hostapd_constants.N_CAPABILITY_TX_STBC,
+                    hostapd_constants.N_CAPABILITY_RX_STBC1,
+                    hostapd_constants.N_CAPABILITY_DSSS_CCK_40,
+                ],
+            )
+
+            config = hostapd_config.HostapdConfig(
+                ssid=ssid,
+                hidden=hidden,
+                security=security,
+                pmf_support=pmf_support,
+                interface=interface,
+                mode=mode,
+                force_wmm=force_wmm,
+                beacon_interval=beacon_interval,
+                dtim_period=dtim_period,
+                short_preamble=short_preamble,
+                frequency=frequency,
+                n_capabilities=n_capabilities,
+                frag_threshold=frag_threshold,
+                rts_threshold=rts_threshold,
+                wnm_features=wnm_features,
+                bss_settings=bss_settings,
+                additional_parameters=additional_params,
+                set_ap_defaults_profile=profile_name,
+            )
+        else:
+            interface = iface_wlan_5g
+            vht_bandwidth = _get_or_default(vht_bandwidth, 80)
+            mode = _get_or_default(mode, hostapd_constants.MODE_11AC_MIXED)
+            if hostapd_config.ht40_plus_allowed(channel):
+                extended_channel = hostapd_constants.N_CAPABILITY_HT40_PLUS
+            elif hostapd_config.ht40_minus_allowed(channel):
+                extended_channel = hostapd_constants.N_CAPABILITY_HT40_MINUS
+            # Channel 165 operates in 20MHz with n or ac modes.
+            if channel == 165:
+                mode = hostapd_constants.MODE_11N_MIXED
+                extended_channel = hostapd_constants.N_CAPABILITY_HT20
+            if vht_bandwidth >= 40:
+                n_capabilities = _get_or_default(
+                    n_capabilities,
+                    [
+                        hostapd_constants.N_CAPABILITY_LDPC,
+                        extended_channel,
+                        hostapd_constants.N_CAPABILITY_SGI20,
+                        hostapd_constants.N_CAPABILITY_SGI40,
+                        hostapd_constants.N_CAPABILITY_TX_STBC,
+                        hostapd_constants.N_CAPABILITY_RX_STBC1,
+                    ],
+                )
+            else:
+                n_capabilities = _get_or_default(
+                    n_capabilities,
+                    [
+                        hostapd_constants.N_CAPABILITY_LDPC,
+                        hostapd_constants.N_CAPABILITY_SGI20,
+                        hostapd_constants.N_CAPABILITY_SGI40,
+                        hostapd_constants.N_CAPABILITY_TX_STBC,
+                        hostapd_constants.N_CAPABILITY_RX_STBC1,
+                        hostapd_constants.N_CAPABILITY_HT20,
+                    ],
+                )
+            ac_capabilities = _get_or_default(
+                ac_capabilities,
+                [
+                    hostapd_constants.AC_CAPABILITY_MAX_MPDU_11454,
+                    hostapd_constants.AC_CAPABILITY_RXLDPC,
+                    hostapd_constants.AC_CAPABILITY_SHORT_GI_80,
+                    hostapd_constants.AC_CAPABILITY_TX_STBC_2BY1,
+                    hostapd_constants.AC_CAPABILITY_RX_STBC_1,
+                    hostapd_constants.AC_CAPABILITY_MAX_A_MPDU_LEN_EXP7,
+                    hostapd_constants.AC_CAPABILITY_RX_ANTENNA_PATTERN,
+                    hostapd_constants.AC_CAPABILITY_TX_ANTENNA_PATTERN,
+                    hostapd_constants.AC_CAPABILITY_SU_BEAMFORMER,
+                    hostapd_constants.AC_CAPABILITY_SU_BEAMFORMEE,
+                    hostapd_constants.AC_CAPABILITY_MU_BEAMFORMER,
+                    hostapd_constants.AC_CAPABILITY_SOUNDING_DIMENSION_4,
+                    hostapd_constants.AC_CAPABILITY_BF_ANTENNA_4,
+                ],
+            )
+
+            config = hostapd_config.HostapdConfig(
+                ssid=ssid,
+                hidden=hidden,
+                security=security,
+                pmf_support=pmf_support,
+                interface=interface,
+                mode=mode,
+                force_wmm=force_wmm,
+                vht_channel_width=vht_bandwidth,
+                beacon_interval=beacon_interval,
+                dtim_period=dtim_period,
+                short_preamble=short_preamble,
+                frequency=frequency,
+                frag_threshold=frag_threshold,
+                rts_threshold=rts_threshold,
+                n_capabilities=n_capabilities,
+                ac_capabilities=ac_capabilities,
+                wnm_features=wnm_features,
+                bss_settings=bss_settings,
+                additional_parameters=additional_params,
+                set_ap_defaults_profile=profile_name,
+            )
+    elif profile_name == "actiontec_pk5000":
+        config = actiontec.actiontec_pk5000(
+            iface_wlan_2g=iface_wlan_2g, channel=channel, ssid=ssid, security=security
+        )
+    elif profile_name == "actiontec_mi424wr":
+        config = actiontec.actiontec_mi424wr(
+            iface_wlan_2g=iface_wlan_2g, channel=channel, ssid=ssid, security=security
+        )
+    elif profile_name == "asus_rtac66u":
+        config = asus.asus_rtac66u(
+            iface_wlan_2g=iface_wlan_2g,
+            iface_wlan_5g=iface_wlan_5g,
+            channel=channel,
+            ssid=ssid,
+            security=security,
+        )
+    elif profile_name == "asus_rtac86u":
+        config = asus.asus_rtac86u(
+            iface_wlan_2g=iface_wlan_2g,
+            iface_wlan_5g=iface_wlan_5g,
+            channel=channel,
+            ssid=ssid,
+            security=security,
+        )
+    elif profile_name == "asus_rtac5300":
+        config = asus.asus_rtac5300(
+            iface_wlan_2g=iface_wlan_2g,
+            iface_wlan_5g=iface_wlan_5g,
+            channel=channel,
+            ssid=ssid,
+            security=security,
+        )
+    elif profile_name == "asus_rtn56u":
+        config = asus.asus_rtn56u(
+            iface_wlan_2g=iface_wlan_2g,
+            iface_wlan_5g=iface_wlan_5g,
+            channel=channel,
+            ssid=ssid,
+            security=security,
+        )
+    elif profile_name == "asus_rtn66u":
+        config = asus.asus_rtn66u(
+            iface_wlan_2g=iface_wlan_2g,
+            iface_wlan_5g=iface_wlan_5g,
+            channel=channel,
+            ssid=ssid,
+            security=security,
+        )
+    elif profile_name == "belkin_f9k1001v5":
+        config = belkin.belkin_f9k1001v5(
+            iface_wlan_2g=iface_wlan_2g, channel=channel, ssid=ssid, security=security
+        )
+    elif profile_name == "linksys_ea4500":
+        config = linksys.linksys_ea4500(
+            iface_wlan_2g=iface_wlan_2g,
+            iface_wlan_5g=iface_wlan_5g,
+            channel=channel,
+            ssid=ssid,
+            security=security,
+        )
+    elif profile_name == "linksys_ea9500":
+        config = linksys.linksys_ea9500(
+            iface_wlan_2g=iface_wlan_2g,
+            iface_wlan_5g=iface_wlan_5g,
+            channel=channel,
+            ssid=ssid,
+            security=security,
+        )
+    elif profile_name == "linksys_wrt1900acv2":
+        config = linksys.linksys_wrt1900acv2(
+            iface_wlan_2g=iface_wlan_2g,
+            iface_wlan_5g=iface_wlan_5g,
+            channel=channel,
+            ssid=ssid,
+            security=security,
+        )
+    elif profile_name == "netgear_r7000":
+        config = netgear.netgear_r7000(
+            iface_wlan_2g=iface_wlan_2g,
+            iface_wlan_5g=iface_wlan_5g,
+            channel=channel,
+            ssid=ssid,
+            security=security,
+        )
+    elif profile_name == "netgear_wndr3400":
+        config = netgear.netgear_wndr3400(
+            iface_wlan_2g=iface_wlan_2g,
+            iface_wlan_5g=iface_wlan_5g,
+            channel=channel,
+            ssid=ssid,
+            security=security,
+        )
+    elif profile_name == "securifi_almond":
+        config = securifi.securifi_almond(
+            iface_wlan_2g=iface_wlan_2g, channel=channel, ssid=ssid, security=security
+        )
+    elif profile_name == "tplink_archerc5":
+        config = tplink.tplink_archerc5(
+            iface_wlan_2g=iface_wlan_2g,
+            iface_wlan_5g=iface_wlan_5g,
+            channel=channel,
+            ssid=ssid,
+            security=security,
+        )
+    elif profile_name == "tplink_archerc7":
+        config = tplink.tplink_archerc7(
+            iface_wlan_2g=iface_wlan_2g,
+            iface_wlan_5g=iface_wlan_5g,
+            channel=channel,
+            ssid=ssid,
+            security=security,
+        )
+    elif profile_name == "tplink_c1200":
+        config = tplink.tplink_c1200(
+            iface_wlan_2g=iface_wlan_2g,
+            iface_wlan_5g=iface_wlan_5g,
+            channel=channel,
+            ssid=ssid,
+            security=security,
+        )
+    elif profile_name == "tplink_tlwr940n":
+        config = tplink.tplink_tlwr940n(
+            iface_wlan_2g=iface_wlan_2g, channel=channel, ssid=ssid, security=security
+        )
+    else:
+        raise ValueError(f"Invalid ap model specified ({profile_name})")
+
+    return config
diff --git a/packages/antlion/controllers/ap_lib/hostapd_bss_settings.py b/packages/antlion/controllers/ap_lib/hostapd_bss_settings.py
new file mode 100644
index 0000000..2f4d261
--- /dev/null
+++ b/packages/antlion/controllers/ap_lib/hostapd_bss_settings.py
@@ -0,0 +1,61 @@
+# 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 collections
+
+from antlion.controllers.ap_lib.hostapd_security import Security
+
+
+class BssSettings(object):
+    """Settings for a bss.
+
+    Settings for a bss to allow multiple network on a single device.
+
+    Attributes:
+        name: The name that this bss will go by.
+        ssid: The name of the ssid to broadcast.
+        hidden: If true then the ssid will be hidden.
+        security: The security settings to use.
+        bssid: The bssid to use.
+    """
+
+    def __init__(
+        self,
+        name: str,
+        ssid: str,
+        security: Security,
+        hidden: bool = False,
+        bssid: str | None = None,
+    ):
+        self.name = name
+        self.ssid = ssid
+        self.security = security
+        self.hidden = hidden
+        self.bssid = bssid
+
+    def generate_dict(self) -> dict[str, str | int]:
+        """Returns: A dictionary of bss settings."""
+        settings: dict[str, str | int] = collections.OrderedDict()
+        settings["bss"] = self.name
+        if self.bssid:
+            settings["bssid"] = self.bssid
+        if self.ssid:
+            settings["ssid"] = self.ssid
+            settings["ignore_broadcast_ssid"] = 1 if self.hidden else 0
+
+        security_settings = self.security.generate_dict()
+        for k, v in security_settings.items():
+            settings[k] = v
+
+        return settings
diff --git a/packages/antlion/controllers/ap_lib/hostapd_config.py b/packages/antlion/controllers/ap_lib/hostapd_config.py
new file mode 100644
index 0000000..749e585
--- /dev/null
+++ b/packages/antlion/controllers/ap_lib/hostapd_config.py
@@ -0,0 +1,710 @@
+# 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 collections
+import logging
+from typing import Any, FrozenSet
+
+from antlion.controllers.ap_lib import hostapd_constants
+from antlion.controllers.ap_lib.hostapd_bss_settings import BssSettings
+from antlion.controllers.ap_lib.hostapd_security import Security, SecurityMode
+
+
+def ht40_plus_allowed(channel: int):
+    """Returns: True iff HT40+ is enabled for this configuration."""
+    channel_supported = (
+        channel
+        in hostapd_constants.HT40_ALLOW_MAP[
+            hostapd_constants.N_CAPABILITY_HT40_PLUS_CHANNELS
+        ]
+    )
+    return channel_supported
+
+
+def ht40_minus_allowed(channel: int):
+    """Returns: True iff HT40- is enabled for this configuration."""
+    channel_supported = (
+        channel
+        in hostapd_constants.HT40_ALLOW_MAP[
+            hostapd_constants.N_CAPABILITY_HT40_MINUS_CHANNELS
+        ]
+    )
+    return channel_supported
+
+
+def get_frequency_for_channel(channel: int):
+    """The frequency associated with a given channel number.
+
+    Args:
+        value: channel number.
+
+    Returns:
+        int, frequency in MHz associated with the channel.
+
+    """
+    for frequency, channel_iter in hostapd_constants.CHANNEL_MAP.items():
+        if channel == channel_iter:
+            return frequency
+    else:
+        raise ValueError(f"Unknown channel value: {channel!r}.")
+
+
+def get_channel_for_frequency(frequency: int):
+    """The channel number associated with a given frequency.
+
+    Args:
+        value: frequency in MHz.
+
+    Returns:
+        int, frequency associated with the channel.
+
+    """
+    return hostapd_constants.CHANNEL_MAP[frequency]
+
+
+class HostapdConfig(object):
+    """The root settings for the router.
+
+    All the settings for a router that are not part of an ssid.
+    """
+
+    def __init__(
+        self,
+        interface: str | None = None,
+        mode: str | None = None,
+        channel: int | None = None,
+        frequency: int | None = None,
+        n_capabilities: list[Any] | None = None,
+        beacon_interval: int | None = None,
+        dtim_period: int | None = None,
+        frag_threshold: int | None = None,
+        rts_threshold: int | None = None,
+        short_preamble: bool | None = None,
+        ssid: str | None = None,
+        hidden: bool = False,
+        security: Security | None = None,
+        bssid: str | None = None,
+        force_wmm: bool | None = None,
+        pmf_support: int | None = None,
+        obss_interval: int | None = None,
+        vht_channel_width: Any | None = None,
+        vht_center_channel: int | None = None,
+        ac_capabilities: list[Any] | None = None,
+        beacon_footer: str = "",
+        spectrum_mgmt_required: bool | None = None,
+        scenario_name: str | None = None,
+        min_streams: int | None = None,
+        wnm_features: FrozenSet[hostapd_constants.WnmFeature] = frozenset(),
+        bss_settings: list[Any] | None = None,
+        additional_parameters: dict[str, Any] | None = None,
+        set_ap_defaults_profile: str = "whirlwind",
+    ) -> None:
+        """Construct a HostapdConfig.
+
+        You may specify channel or frequency, but not both.  Both options
+        are checked for validity (i.e. you can't specify an invalid channel
+        or a frequency that will not be accepted).
+
+        Args:
+            interface: The name of the interface to use.
+            mode: MODE_11x defined above.
+            channel: Channel number.
+            frequency: Frequency of channel.
+            n_capabilities: List of N_CAPABILITY_x defined above.
+            beacon_interval: Beacon interval of AP.
+            dtim_period: Include a DTIM every |dtim_period| beacons.
+            frag_threshold: Maximum outgoing data frame size.
+            rts_threshold: Maximum packet size without requiring explicit
+                protection via rts/cts or cts to self.
+            short_preamble: Whether to use a short preamble.
+            ssid: string, The name of the ssid to broadcast.
+            hidden: Should the ssid be hidden.
+            security: The security settings to use.
+            bssid: A MAC address like string for the BSSID.
+            force_wmm: True if we should force WMM on, False if we should
+                force it off, None if we shouldn't force anything.
+            pmf_support: One of PMF_SUPPORT_* above.  Controls whether the
+                client supports/must support 802.11w. If None, defaults to
+                required with wpa3, else defaults to disabled.
+            obss_interval: Interval in seconds that client should be
+                required to do background scans for overlapping BSSes.
+            vht_channel_width: Object channel width
+            vht_center_channel: Center channel of segment 0.
+            ac_capabilities: List of AC_CAPABILITY_x defined above.
+            beacon_footer: Containing (not validated) IE data to be
+                placed at the end of the beacon.
+            spectrum_mgmt_required: True if we require the DUT to support
+                spectrum management.
+            scenario_name: To be included in file names, instead
+                of the interface name.
+            min_streams: Number of spatial streams required.
+            wnm_features: WNM features to enable on the AP.
+            control_interface: The file name to use as the control interface.
+            bss_settings: The settings for all bss.
+            additional_parameters: A dictionary of additional parameters to add
+                to the hostapd config.
+            set_ap_defaults_profile: profile name to load defaults from
+        """
+        if n_capabilities is None:
+            n_capabilities = []
+        if ac_capabilities is None:
+            ac_capabilities = []
+        if bss_settings is None:
+            bss_settings = []
+        if additional_parameters is None:
+            additional_parameters = {}
+        if security is None:
+            security = Security()
+
+        self.set_ap_defaults_profile = set_ap_defaults_profile
+        self._interface = interface
+        if channel is not None and frequency is not None:
+            raise ValueError("Specify either frequency or channel " "but not both.")
+
+        unknown_caps = [
+            cap
+            for cap in n_capabilities
+            if cap not in hostapd_constants.N_CAPABILITIES_MAPPING
+        ]
+        if unknown_caps:
+            raise ValueError(f"Unknown capabilities: {unknown_caps!r}")
+
+        if channel:
+            self.channel = channel
+        elif frequency:
+            self.frequency = frequency
+        else:
+            raise ValueError("Specify either frequency or channel.")
+
+        self._n_capabilities = set(n_capabilities)
+        if force_wmm is not None:
+            self._wmm_enabled = force_wmm
+        elif self._n_capabilities:
+            self._wmm_enabled = True
+        if self._n_capabilities and mode is None:
+            mode = hostapd_constants.MODE_11N_PURE
+        self._mode = mode
+
+        if not self.supports_frequency(self.frequency):
+            raise ValueError(
+                "Configured a mode %s that does not support "
+                "frequency %d" % (self._mode, self.frequency)
+            )
+
+        self._beacon_interval = beacon_interval
+        self._dtim_period = dtim_period
+        self._frag_threshold = frag_threshold
+        self._rts_threshold = rts_threshold
+        self._short_preamble = short_preamble
+        self._ssid = ssid
+        self._hidden = hidden
+        self._security = security
+        self._bssid = bssid
+        # Default PMF Values
+        if pmf_support is None:
+            if self.security and self.security.security_mode is SecurityMode.WPA3:
+                # Set PMF required for WP3
+                self._pmf_support = hostapd_constants.PMF_SUPPORT_REQUIRED
+            elif self.security and self.security.security_mode.is_wpa3():
+                # Default PMF to enabled for WPA3 mixed modes (can be
+                # overwritten by explicitly provided value)
+                self._pmf_support = hostapd_constants.PMF_SUPPORT_ENABLED
+            else:
+                # Default PMD to disabled for all other modes (can be
+                # overwritten by explicitly provided value)
+                self._pmf_support = hostapd_constants.PMF_SUPPORT_DISABLED
+        elif pmf_support not in hostapd_constants.PMF_SUPPORT_VALUES:
+            raise ValueError(f"Invalid value for pmf_support: {pmf_support!r}")
+        elif (
+            pmf_support != hostapd_constants.PMF_SUPPORT_REQUIRED
+            and self.security
+            and self.security.security_mode is SecurityMode.WPA3
+        ):
+            raise ValueError("PMF support must be required with wpa3.")
+        else:
+            self._pmf_support = pmf_support
+        self._obss_interval = obss_interval
+        if self.is_11ac:
+            if str(vht_channel_width) == "40" or str(vht_channel_width) == "20":
+                self._vht_oper_chwidth = hostapd_constants.VHT_CHANNEL_WIDTH_40
+            elif str(vht_channel_width) == "80":
+                self._vht_oper_chwidth = hostapd_constants.VHT_CHANNEL_WIDTH_80
+            elif str(vht_channel_width) == "160":
+                self._vht_oper_chwidth = hostapd_constants.VHT_CHANNEL_WIDTH_160
+            elif str(vht_channel_width) == "80+80":
+                self._vht_oper_chwidth = hostapd_constants.VHT_CHANNEL_WIDTH_80_80
+            elif vht_channel_width is not None:
+                raise ValueError("Invalid channel width")
+            else:
+                logging.warning(
+                    "No channel bandwidth specified.  Using 80MHz for 11ac."
+                )
+                self._vht_oper_chwidth = 1
+            if vht_center_channel is not None:
+                self._vht_oper_centr_freq_seg0_idx = vht_center_channel
+            elif vht_channel_width == 20 and channel is not None:
+                self._vht_oper_centr_freq_seg0_idx = channel
+            else:
+                self._vht_oper_centr_freq_seg0_idx = (
+                    self._get_11ac_center_channel_from_channel(self.channel)
+                )
+            self._ac_capabilities = set(ac_capabilities)
+        self._beacon_footer = beacon_footer
+        self._spectrum_mgmt_required = spectrum_mgmt_required
+        self._scenario_name = scenario_name
+        self._min_streams = min_streams
+        self._wnm_features = wnm_features
+        self._additional_parameters = additional_parameters
+
+        self._bss_lookup: dict[str, BssSettings] = collections.OrderedDict()
+        for bss in bss_settings:
+            if bss.name in self._bss_lookup:
+                raise ValueError(
+                    "Cannot have multiple bss settings with the same name."
+                )
+            self._bss_lookup[bss.name] = bss
+
+    def _get_11ac_center_channel_from_channel(self, channel: int) -> int:
+        """Returns the center channel of the selected channel band based
+        on the channel and channel bandwidth provided.
+        """
+        channel = int(channel)
+        center_channel_delta = hostapd_constants.CENTER_CHANNEL_MAP[
+            self._vht_oper_chwidth
+        ]["delta"]
+
+        for channel_map in hostapd_constants.CENTER_CHANNEL_MAP[self._vht_oper_chwidth][
+            "channels"
+        ]:
+            lower_channel_bound, upper_channel_bound = channel_map
+            if lower_channel_bound <= channel <= upper_channel_bound:
+                return lower_channel_bound + center_channel_delta
+        raise ValueError(f"Invalid channel for {self._vht_oper_chwidth}.")
+
+    @property
+    def _get_default_config(self):
+        """Returns: dict of default options for hostapd."""
+        if self.set_ap_defaults_profile == "mistral":
+            return collections.OrderedDict(
+                [
+                    ("logger_syslog", "-1"),
+                    ("logger_syslog_level", "0"),
+                    # default RTS and frag threshold to ``off''
+                    ("rts_threshold", None),
+                    ("fragm_threshold", None),
+                    ("driver", hostapd_constants.DRIVER_NAME),
+                ]
+            )
+        else:
+            return collections.OrderedDict(
+                [
+                    ("logger_syslog", "-1"),
+                    ("logger_syslog_level", "0"),
+                    # default RTS and frag threshold to ``off''
+                    ("rts_threshold", "2347"),
+                    ("fragm_threshold", "2346"),
+                    ("driver", hostapd_constants.DRIVER_NAME),
+                ]
+            )
+
+    @property
+    def _hostapd_ht_capabilities(self):
+        """Returns: string suitable for the ht_capab= line in a hostapd config."""
+        ret = []
+        for cap in hostapd_constants.N_CAPABILITIES_MAPPING.keys():
+            if cap in self._n_capabilities:
+                ret.append(hostapd_constants.N_CAPABILITIES_MAPPING[cap])
+        return "".join(ret)
+
+    @property
+    def _hostapd_vht_capabilities(self):
+        """Returns: string suitable for the vht_capab= line in a hostapd config."""
+        ret = []
+        for cap in hostapd_constants.AC_CAPABILITIES_MAPPING.keys():
+            if cap in self._ac_capabilities:
+                ret.append(hostapd_constants.AC_CAPABILITIES_MAPPING[cap])
+        return "".join(ret)
+
+    @property
+    def _require_ht(self):
+        """Returns: True iff clients should be required to support HT."""
+        return self._mode == hostapd_constants.MODE_11N_PURE
+
+    @property
+    def _require_vht(self):
+        """Returns: True if clients should be required to support VHT."""
+        return self._mode == hostapd_constants.MODE_11AC_PURE
+
+    @property
+    def hw_mode(self):
+        """Returns: string hardware mode understood by hostapd."""
+        if self._mode == hostapd_constants.MODE_11A:
+            return hostapd_constants.MODE_11A
+        if self._mode == hostapd_constants.MODE_11B:
+            return hostapd_constants.MODE_11B
+        if self._mode == hostapd_constants.MODE_11G:
+            return hostapd_constants.MODE_11G
+        if self.is_11n or self.is_11ac:
+            # For their own historical reasons, hostapd wants it this way.
+            if self._frequency > 5000:
+                return hostapd_constants.MODE_11A
+            return hostapd_constants.MODE_11G
+        raise ValueError("Invalid mode.")
+
+    @property
+    def is_11n(self):
+        """Returns: True if we're trying to host an 802.11n network."""
+        return self._mode in (
+            hostapd_constants.MODE_11N_MIXED,
+            hostapd_constants.MODE_11N_PURE,
+        )
+
+    @property
+    def is_11ac(self):
+        """Returns: True if we're trying to host an 802.11ac network."""
+        return self._mode in (
+            hostapd_constants.MODE_11AC_MIXED,
+            hostapd_constants.MODE_11AC_PURE,
+        )
+
+    @property
+    def channel(self):
+        """Returns: int channel number for self.frequency."""
+        return get_channel_for_frequency(self.frequency)
+
+    @channel.setter
+    def channel(self, value):
+        """Sets the channel number to configure hostapd to listen on.
+
+        Args:
+            value: int, channel number.
+
+        """
+        self.frequency = get_frequency_for_channel(value)
+
+    @property
+    def bssid(self) -> str | None:
+        return self._bssid
+
+    @bssid.setter
+    def bssid(self, value: str):
+        self._bssid = value
+
+    @property
+    def frequency(self) -> int:
+        """Returns: frequency for hostapd to listen on."""
+        return self._frequency
+
+    @frequency.setter
+    def frequency(self, value: int):
+        """Sets the frequency for hostapd to listen on.
+
+        Args:
+            value: int, frequency in MHz.
+
+        """
+        if value not in hostapd_constants.CHANNEL_MAP:
+            raise ValueError(f"Tried to set an invalid frequency: {value!r}.")
+
+        self._frequency = value
+
+    @property
+    def bss_lookup(self) -> dict[str, BssSettings]:
+        return self._bss_lookup
+
+    @property
+    def ssid(self) -> str | None:
+        """Returns: SsidSettings, The root Ssid settings being used."""
+        return self._ssid
+
+    @ssid.setter
+    def ssid(self, value: str):
+        """Sets the ssid for the hostapd.
+
+        Args:
+            value: SsidSettings, new ssid settings to use.
+
+        """
+        self._ssid = value
+
+    @property
+    def hidden(self):
+        """Returns: bool, True if the ssid is hidden, false otherwise."""
+        return self._hidden
+
+    @hidden.setter
+    def hidden(self, value: bool):
+        """Sets if this ssid is hidden.
+
+        Args:
+            value: If true the ssid will be hidden.
+        """
+        self.hidden = value
+
+    @property
+    def security(self) -> Security:
+        """Returns: The security type being used."""
+        return self._security
+
+    @security.setter
+    def security(self, value: Security):
+        """Sets the security options to use.
+
+        Args:
+            value: The type of security to use.
+        """
+        self._security = value
+
+    @property
+    def ht_packet_capture_mode(self) -> str | None:
+        """Get an appropriate packet capture HT parameter.
+
+        When we go to configure a raw monitor we need to configure
+        the phy to listen on the correct channel.  Part of doing
+        so is to specify the channel width for HT channels.  In the
+        case that the AP is configured to be either HT40+ or HT40-,
+        we could return the wrong parameter because we don't know which
+        configuration will be chosen by hostap.
+
+        Returns:
+            string, HT parameter for frequency configuration.
+
+        """
+        if not self.is_11n:
+            return None
+
+        if ht40_plus_allowed(self.channel):
+            return "HT40+"
+
+        if ht40_minus_allowed(self.channel):
+            return "HT40-"
+
+        return "HT20"
+
+    @property
+    def beacon_footer(self) -> str:
+        return self._beacon_footer
+
+    @beacon_footer.setter
+    def beacon_footer(self, value: str):
+        """Changes the beacon footer.
+
+        Args:
+            value: The beacon footer value.
+        """
+        self._beacon_footer = value
+
+    @property
+    def scenario_name(self) -> str | None:
+        return self._scenario_name
+
+    @property
+    def min_streams(self) -> int | None:
+        return self._min_streams
+
+    @property
+    def wnm_features(self) -> FrozenSet[hostapd_constants.WnmFeature]:
+        return self._wnm_features
+
+    @wnm_features.setter
+    def wnm_features(self, value: FrozenSet[hostapd_constants.WnmFeature]):
+        self._wnm_features = value
+
+    def __repr__(self) -> str:
+        return (
+            "%s(mode=%r, channel=%r, frequency=%r, "
+            "n_capabilities=%r, beacon_interval=%r, "
+            "dtim_period=%r, frag_threshold=%r, ssid=%r, bssid=%r, "
+            "wmm_enabled=%r, security_config=%r, "
+            "spectrum_mgmt_required=%r)"
+            % (
+                self.__class__.__name__,
+                self._mode,
+                self.channel,
+                self.frequency,
+                self._n_capabilities,
+                self._beacon_interval,
+                self._dtim_period,
+                self._frag_threshold,
+                self._ssid,
+                self._bssid,
+                self._wmm_enabled,
+                self._security,
+                self._spectrum_mgmt_required,
+            )
+        )
+
+    def supports_channel(self, value: int) -> bool:
+        """Check whether channel is supported by the current hardware mode.
+
+        @param value: channel to check.
+        @return True iff the current mode supports the band of the channel.
+
+        """
+        for freq, channel in hostapd_constants.CHANNEL_MAP.items():
+            if channel == value:
+                return self.supports_frequency(freq)
+
+        return False
+
+    def supports_frequency(self, frequency: int) -> bool:
+        """Check whether frequency is supported by the current hardware mode.
+
+        @param frequency: frequency to check.
+        @return True iff the current mode supports the band of the frequency.
+
+        """
+        if self._mode == hostapd_constants.MODE_11A and frequency < 5000:
+            return False
+
+        if (
+            self._mode in (hostapd_constants.MODE_11B, hostapd_constants.MODE_11G)
+            and frequency > 5000
+        ):
+            return False
+
+        if frequency not in hostapd_constants.CHANNEL_MAP:
+            return False
+
+        channel = hostapd_constants.CHANNEL_MAP[frequency]
+        supports_plus = (
+            channel
+            in hostapd_constants.HT40_ALLOW_MAP[
+                hostapd_constants.N_CAPABILITY_HT40_PLUS_CHANNELS
+            ]
+        )
+        supports_minus = (
+            channel
+            in hostapd_constants.HT40_ALLOW_MAP[
+                hostapd_constants.N_CAPABILITY_HT40_MINUS_CHANNELS
+            ]
+        )
+        if (
+            hostapd_constants.N_CAPABILITY_HT40_PLUS in self._n_capabilities
+            and not supports_plus
+        ):
+            return False
+
+        if (
+            hostapd_constants.N_CAPABILITY_HT40_MINUS in self._n_capabilities
+            and not supports_minus
+        ):
+            return False
+
+        return True
+
+    def add_bss(self, bss: BssSettings) -> None:
+        """Adds a new bss setting.
+
+        Args:
+            bss: The bss settings to add.
+        """
+        if bss.name in self._bss_lookup:
+            raise ValueError("A bss with the same name already exists.")
+
+        self._bss_lookup[bss.name] = bss
+
+    def remove_bss(self, bss_name: str) -> None:
+        """Removes a bss setting from the config."""
+        del self._bss_lookup[bss_name]
+
+    def package_configs(self) -> list[dict[str, str | int]]:
+        """Package the configs.
+
+        Returns:
+            A list of dictionaries, one dictionary for each section of the
+            config.
+        """
+        # Start with the default config parameters.
+        conf = self._get_default_config
+
+        if self._interface:
+            conf["interface"] = self._interface
+        if self._bssid:
+            conf["bssid"] = self._bssid
+        if self._ssid:
+            conf["ssid"] = self._ssid
+            conf["ignore_broadcast_ssid"] = 1 if self._hidden else 0
+        conf["channel"] = self.channel
+        conf["hw_mode"] = self.hw_mode
+        if self.is_11n or self.is_11ac:
+            conf["ieee80211n"] = 1
+            conf["ht_capab"] = self._hostapd_ht_capabilities
+        if self.is_11ac:
+            conf["ieee80211ac"] = 1
+            conf["vht_oper_chwidth"] = self._vht_oper_chwidth
+            conf["vht_oper_centr_freq_seg0_idx"] = self._vht_oper_centr_freq_seg0_idx
+            conf["vht_capab"] = self._hostapd_vht_capabilities
+        if self._wmm_enabled is not None:
+            conf["wmm_enabled"] = 1 if self._wmm_enabled else 0
+        if self._require_ht:
+            conf["require_ht"] = 1
+        if self._require_vht:
+            conf["require_vht"] = 1
+        if self._beacon_interval:
+            conf["beacon_int"] = self._beacon_interval
+        if self._dtim_period:
+            conf["dtim_period"] = self._dtim_period
+        if self._frag_threshold:
+            conf["fragm_threshold"] = self._frag_threshold
+        if self._rts_threshold:
+            conf["rts_threshold"] = self._rts_threshold
+        if self._pmf_support:
+            conf["ieee80211w"] = self._pmf_support
+        if self._obss_interval:
+            conf["obss_interval"] = self._obss_interval
+        if self._short_preamble:
+            conf["preamble"] = 1
+        if self._spectrum_mgmt_required:
+            # To set spectrum_mgmt_required, we must first set
+            # local_pwr_constraint. And to set local_pwr_constraint,
+            # we must first set ieee80211d. And to set ieee80211d, ...
+            # Point being: order matters here.
+            conf["country_code"] = "US"  # Required for local_pwr_constraint
+            conf["ieee80211d"] = 1  # Required for local_pwr_constraint
+            conf["local_pwr_constraint"] = 0  # No local constraint
+            conf["spectrum_mgmt_required"] = 1  # Requires local_pwr_constraint
+
+        for k, v in self._security.generate_dict().items():
+            conf[k] = v
+
+        for wnm_feature in self._wnm_features:
+            if wnm_feature == hostapd_constants.WnmFeature.TIME_ADVERTISEMENT:
+                conf.update(hostapd_constants.ENABLE_WNM_TIME_ADVERTISEMENT)
+            elif wnm_feature == hostapd_constants.WnmFeature.WNM_SLEEP_MODE:
+                conf.update(hostapd_constants.ENABLE_WNM_SLEEP_MODE)
+            elif wnm_feature == hostapd_constants.WnmFeature.BSS_TRANSITION_MANAGEMENT:
+                conf.update(hostapd_constants.ENABLE_WNM_BSS_TRANSITION_MANAGEMENT)
+            elif wnm_feature == hostapd_constants.WnmFeature.PROXY_ARP:
+                conf.update(hostapd_constants.ENABLE_WNM_PROXY_ARP)
+            elif (
+                wnm_feature
+                == hostapd_constants.WnmFeature.IPV6_NEIGHBOR_ADVERTISEMENT_MULTICAST_TO_UNICAST
+            ):
+                conf.update(
+                    hostapd_constants.ENABLE_WNM_IPV6_NEIGHBOR_ADVERTISEMENT_MULTICAST_TO_UNICAST
+                )
+
+        all_conf = [conf]
+
+        for bss in self._bss_lookup.values():
+            bss_conf = collections.OrderedDict()
+            for k, v in (bss.generate_dict()).items():
+                bss_conf[k] = v
+            all_conf.append(bss_conf)
+
+        if self._additional_parameters:
+            all_conf.append(self._additional_parameters)
+
+        return all_conf
diff --git a/packages/antlion/controllers/ap_lib/hostapd_constants.py b/packages/antlion/controllers/ap_lib/hostapd_constants.py
new file mode 100755
index 0000000..ea6fdb2
--- /dev/null
+++ b/packages/antlion/controllers/ap_lib/hostapd_constants.py
@@ -0,0 +1,938 @@
+#!/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
+from enum import Enum, StrEnum, auto, unique
+from typing import TypedDict
+
+# TODO(http://b/286584981): Replace with BandType
+BAND_2G = "2g"
+BAND_5G = "5g"
+
+
+@unique
+class BandType(StrEnum):
+    BAND_2G = "2g"
+    BAND_5G = "5g"
+
+    def default_channel(self) -> int:
+        match self:
+            case BandType.BAND_2G:
+                return 6
+            case BandType.BAND_5G:
+                return 36
+
+
+CHANNEL_BANDWIDTH_20MHZ = 20
+CHANNEL_BANDWIDTH_40MHZ = 40
+CHANNEL_BANDWIDTH_80MHZ = 80
+CHANNEL_BANDWIDTH_160MHZ = 160
+
+# TODO(http://b/286584981): Replace with SecurityModeInt
+WEP = 0
+WPA1 = 1
+WPA2 = 2
+WPA3 = 2  # same as wpa2 and wpa2/wpa3, distinguished by wpa_key_mgmt
+MIXED = 3  # applies to wpa/wpa2, and wpa/wpa2/wpa3, distinguished by wpa_key_mgmt
+ENT = 4  # get the correct constant
+
+MAX_WPA_PSK_LENGTH = 64
+MIN_WPA_PSK_LENGTH = 8
+MAX_WPA_PASSWORD_LENGTH = 63
+WPA_STRICT_REKEY = 1
+WPA_DEFAULT_CIPHER = "TKIP"
+WPA2_DEFAULT_CIPER = "CCMP"
+WPA_GROUP_KEY_ROTATION_TIME = 600
+WPA_STRICT_REKEY_DEFAULT = True
+
+# TODO(http://b/286584981): Replace these with SecurityMode enum
+WEP_STRING = "wep"
+WPA_STRING = "wpa"
+WPA2_STRING = "wpa2"
+WPA_MIXED_STRING = "wpa/wpa2"
+WPA3_STRING = "wpa3"
+WPA2_WPA3_MIXED_STRING = "wpa2/wpa3"
+WPA_WPA2_WPA3_MIXED_STRING = "wpa/wpa2/wpa3"
+ENT_STRING = "ent"
+
+# TODO(http://b/286584981): Replace with KeyManagement
+ENT_KEY_MGMT = "WPA-EAP"
+WPA_PSK_KEY_MGMT = "WPA-PSK"
+SAE_KEY_MGMT = "SAE"
+DUAL_WPA_PSK_SAE_KEY_MGMT = "WPA-PSK SAE"
+
+# TODO(http://b/286584981): Replace with SecurityMode.security_mode_int
+SECURITY_STRING_TO_SECURITY_MODE_INT = {
+    WPA_STRING: WPA1,
+    WPA2_STRING: WPA2,
+    WPA_MIXED_STRING: MIXED,
+    WPA3_STRING: WPA3,
+    WPA2_WPA3_MIXED_STRING: WPA3,
+    WPA_WPA2_WPA3_MIXED_STRING: MIXED,
+    WEP_STRING: WEP,
+    ENT_STRING: ENT,
+}
+
+# TODO(http://b/286584981): Replace with SecurityMode.key_management
+SECURITY_STRING_TO_WPA_KEY_MGMT = {
+    WPA_STRING: WPA_PSK_KEY_MGMT,
+    WPA2_STRING: WPA_PSK_KEY_MGMT,
+    WPA_MIXED_STRING: WPA_PSK_KEY_MGMT,
+    WPA3_STRING: SAE_KEY_MGMT,
+    WPA2_WPA3_MIXED_STRING: DUAL_WPA_PSK_SAE_KEY_MGMT,
+    WPA_WPA2_WPA3_MIXED_STRING: DUAL_WPA_PSK_SAE_KEY_MGMT,
+}
+
+# TODO(http://b/286584981): Replace with SecurityMode.fuchsia_security_type
+SECURITY_STRING_TO_DEFAULT_TARGET_SECURITY = {
+    WEP_STRING: WEP_STRING,
+    WPA_STRING: WPA_STRING,
+    WPA2_STRING: WPA2_STRING,
+    WPA_MIXED_STRING: WPA2_STRING,
+    WPA3_STRING: WPA3_STRING,
+    WPA2_WPA3_MIXED_STRING: WPA3_STRING,
+    WPA_WPA2_WPA3_MIXED_STRING: WPA3_STRING,
+}
+
+IEEE8021X = 1
+WLAN0_STRING = "wlan0"
+WLAN1_STRING = "wlan1"
+WLAN2_STRING = "wlan2"
+WLAN3_STRING = "wlan3"
+WLAN0_GALE = "wlan-2400mhz"
+WLAN1_GALE = "wlan-5000mhz"
+WEP_DEFAULT_KEY = 0
+WEP_HEX_LENGTH = [10, 26, 32, 58]
+WEP_STR_LENGTH = [5, 13, 16]
+WEP_DEFAULT_STR_LENGTH = 13
+
+# TODO(http://b/286584981): Replace with BandType.default_channel()
+AP_DEFAULT_CHANNEL_2G = 6
+AP_DEFAULT_CHANNEL_5G = 36
+
+AP_DEFAULT_MAX_SSIDS_2G = 8
+AP_DEFAULT_MAX_SSIDS_5G = 8
+AP_SSID_LENGTH_2G = 8
+AP_SSID_MIN_LENGTH_2G = 1
+AP_SSID_MAX_LENGTH_2G = 32
+AP_PASSPHRASE_LENGTH_2G = 10
+AP_SSID_LENGTH_5G = 8
+AP_SSID_MIN_LENGTH_5G = 1
+AP_SSID_MAX_LENGTH_5G = 32
+AP_PASSPHRASE_LENGTH_5G = 10
+INTERFACE_2G_LIST = [WLAN0_STRING, WLAN0_GALE]
+INTERFACE_5G_LIST = [WLAN1_STRING, WLAN1_GALE]
+HIGH_BEACON_INTERVAL = 300
+LOW_BEACON_INTERVAL = 100
+HIGH_DTIM = 3
+LOW_DTIM = 1
+
+# A mapping of frequency to channel number.  This includes some
+# frequencies used outside the US.
+CHANNEL_MAP = {
+    2412: 1,
+    2417: 2,
+    2422: 3,
+    2427: 4,
+    2432: 5,
+    2437: 6,
+    2442: 7,
+    2447: 8,
+    2452: 9,
+    2457: 10,
+    2462: 11,
+    # 12, 13 are only legitimate outside the US.
+    2467: 12,
+    2472: 13,
+    # 14 is for Japan, DSSS and CCK only.
+    2484: 14,
+    # 34 valid in Japan.
+    5170: 34,
+    # 36-116 valid in the US, except 38, 42, and 46, which have
+    # mixed international support.
+    5180: 36,
+    5190: 38,
+    5200: 40,
+    5210: 42,
+    5220: 44,
+    5230: 46,
+    5240: 48,
+    # DFS channels.
+    5260: 52,
+    5280: 56,
+    5300: 60,
+    5320: 64,
+    5500: 100,
+    5520: 104,
+    5540: 108,
+    5560: 112,
+    5580: 116,
+    # 120, 124, 128 valid in Europe/Japan.
+    5600: 120,
+    5620: 124,
+    5640: 128,
+    # 132+ valid in US.
+    5660: 132,
+    5680: 136,
+    5700: 140,
+    # 144 is supported by a subset of WiFi chips
+    # (e.g. bcm4354, but not ath9k).
+    5720: 144,
+    # End DFS channels.
+    5745: 149,
+    5755: 151,
+    5765: 153,
+    5775: 155,
+    5795: 159,
+    5785: 157,
+    5805: 161,
+    5825: 165,
+}
+FREQUENCY_MAP = {v: k for k, v in CHANNEL_MAP.items()}
+
+US_CHANNELS_2G = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]
+US_CHANNELS_5G = [
+    36,
+    40,
+    44,
+    48,
+    52,
+    56,
+    60,
+    64,
+    100,
+    104,
+    108,
+    112,
+    116,
+    120,
+    124,
+    128,
+    132,
+    136,
+    140,
+    144,
+    149,
+    153,
+    157,
+    161,
+    165,
+]
+
+LOWEST_5G_CHANNEL = 36
+
+MODE_11A = "a"
+MODE_11B = "b"
+MODE_11G = "g"
+MODE_11N_MIXED = "n-mixed"
+MODE_11N_PURE = "n-only"
+MODE_11AC_MIXED = "ac-mixed"
+MODE_11AC_PURE = "ac-only"
+
+N_CAPABILITY_LDPC = object()
+N_CAPABILITY_HT20 = object()
+N_CAPABILITY_HT40_PLUS = object()
+N_CAPABILITY_HT40_MINUS = object()
+N_CAPABILITY_GREENFIELD = object()
+N_CAPABILITY_SGI20 = object()
+N_CAPABILITY_SGI40 = object()
+N_CAPABILITY_TX_STBC = object()
+N_CAPABILITY_RX_STBC1 = object()
+N_CAPABILITY_RX_STBC12 = object()
+N_CAPABILITY_RX_STBC123 = object()
+N_CAPABILITY_DSSS_CCK_40 = object()
+N_CAPABILITY_LSIG_TXOP_PROT = object()
+N_CAPABILITY_40_INTOLERANT = object()
+N_CAPABILITY_MAX_AMSDU_7935 = object()
+N_CAPABILITY_DELAY_BLOCK_ACK = object()
+N_CAPABILITY_SMPS_STATIC = object()
+N_CAPABILITY_SMPS_DYNAMIC = object()
+N_CAPABILITIES_MAPPING = {
+    N_CAPABILITY_LDPC: "[LDPC]",
+    N_CAPABILITY_HT20: "[HT20]",
+    N_CAPABILITY_HT40_PLUS: "[HT40+]",
+    N_CAPABILITY_HT40_MINUS: "[HT40-]",
+    N_CAPABILITY_GREENFIELD: "[GF]",
+    N_CAPABILITY_SGI20: "[SHORT-GI-20]",
+    N_CAPABILITY_SGI40: "[SHORT-GI-40]",
+    N_CAPABILITY_TX_STBC: "[TX-STBC]",
+    N_CAPABILITY_RX_STBC1: "[RX-STBC1]",
+    N_CAPABILITY_RX_STBC12: "[RX-STBC12]",
+    N_CAPABILITY_RX_STBC123: "[RX-STBC123]",
+    N_CAPABILITY_DSSS_CCK_40: "[DSSS_CCK-40]",
+    N_CAPABILITY_LSIG_TXOP_PROT: "[LSIG-TXOP-PROT]",
+    N_CAPABILITY_40_INTOLERANT: "[40-INTOLERANT]",
+    N_CAPABILITY_MAX_AMSDU_7935: "[MAX-AMSDU-7935]",
+    N_CAPABILITY_DELAY_BLOCK_ACK: "[DELAYED-BA]",
+    N_CAPABILITY_SMPS_STATIC: "[SMPS-STATIC]",
+    N_CAPABILITY_SMPS_DYNAMIC: "[SMPS-DYNAMIC]",
+}
+N_CAPABILITIES_MAPPING_INVERSE = {v: k for k, v in N_CAPABILITIES_MAPPING.items()}
+N_CAPABILITY_HT40_MINUS_CHANNELS = object()
+N_CAPABILITY_HT40_PLUS_CHANNELS = object()
+AC_CAPABILITY_VHT160 = object()
+AC_CAPABILITY_VHT160_80PLUS80 = object()
+AC_CAPABILITY_RXLDPC = object()
+AC_CAPABILITY_SHORT_GI_80 = object()
+AC_CAPABILITY_SHORT_GI_160 = object()
+AC_CAPABILITY_TX_STBC_2BY1 = object()
+AC_CAPABILITY_RX_STBC_1 = object()
+AC_CAPABILITY_RX_STBC_12 = object()
+AC_CAPABILITY_RX_STBC_123 = object()
+AC_CAPABILITY_RX_STBC_1234 = object()
+AC_CAPABILITY_SU_BEAMFORMER = object()
+AC_CAPABILITY_SU_BEAMFORMEE = object()
+AC_CAPABILITY_BF_ANTENNA_2 = object()
+AC_CAPABILITY_BF_ANTENNA_3 = object()
+AC_CAPABILITY_BF_ANTENNA_4 = object()
+AC_CAPABILITY_SOUNDING_DIMENSION_2 = object()
+AC_CAPABILITY_SOUNDING_DIMENSION_3 = object()
+AC_CAPABILITY_SOUNDING_DIMENSION_4 = object()
+AC_CAPABILITY_MU_BEAMFORMER = object()
+AC_CAPABILITY_MU_BEAMFORMEE = object()
+AC_CAPABILITY_VHT_TXOP_PS = object()
+AC_CAPABILITY_HTC_VHT = object()
+AC_CAPABILITY_MAX_A_MPDU_LEN_EXP0 = object()
+AC_CAPABILITY_MAX_A_MPDU_LEN_EXP1 = object()
+AC_CAPABILITY_MAX_A_MPDU_LEN_EXP2 = object()
+AC_CAPABILITY_MAX_A_MPDU_LEN_EXP3 = object()
+AC_CAPABILITY_MAX_A_MPDU_LEN_EXP4 = object()
+AC_CAPABILITY_MAX_A_MPDU_LEN_EXP5 = object()
+AC_CAPABILITY_MAX_A_MPDU_LEN_EXP6 = object()
+AC_CAPABILITY_MAX_A_MPDU_LEN_EXP7 = object()
+AC_CAPABILITY_VHT_LINK_ADAPT2 = object()
+AC_CAPABILITY_VHT_LINK_ADAPT3 = object()
+AC_CAPABILITY_RX_ANTENNA_PATTERN = object()
+AC_CAPABILITY_TX_ANTENNA_PATTERN = object()
+AC_CAPABILITY_MAX_MPDU_7991 = object()
+AC_CAPABILITY_MAX_MPDU_11454 = object()
+AC_CAPABILITIES_MAPPING = {
+    AC_CAPABILITY_VHT160: "[VHT160]",
+    AC_CAPABILITY_VHT160_80PLUS80: "[VHT160-80PLUS80]",
+    AC_CAPABILITY_RXLDPC: "[RXLDPC]",
+    AC_CAPABILITY_SHORT_GI_80: "[SHORT-GI-80]",
+    AC_CAPABILITY_SHORT_GI_160: "[SHORT-GI-160]",
+    AC_CAPABILITY_TX_STBC_2BY1: "[TX-STBC-2BY1]",
+    AC_CAPABILITY_RX_STBC_1: "[RX-STBC-1]",
+    AC_CAPABILITY_RX_STBC_12: "[RX-STBC-12]",
+    AC_CAPABILITY_RX_STBC_123: "[RX-STBC-123]",
+    AC_CAPABILITY_RX_STBC_1234: "[RX-STBC-1234]",
+    AC_CAPABILITY_SU_BEAMFORMER: "[SU-BEAMFORMER]",
+    AC_CAPABILITY_SU_BEAMFORMEE: "[SU-BEAMFORMEE]",
+    AC_CAPABILITY_BF_ANTENNA_2: "[BF-ANTENNA-2]",
+    AC_CAPABILITY_BF_ANTENNA_3: "[BF-ANTENNA-3]",
+    AC_CAPABILITY_BF_ANTENNA_4: "[BF-ANTENNA-4]",
+    AC_CAPABILITY_SOUNDING_DIMENSION_2: "[SOUNDING-DIMENSION-2]",
+    AC_CAPABILITY_SOUNDING_DIMENSION_3: "[SOUNDING-DIMENSION-3]",
+    AC_CAPABILITY_SOUNDING_DIMENSION_4: "[SOUNDING-DIMENSION-4]",
+    AC_CAPABILITY_MU_BEAMFORMER: "[MU-BEAMFORMER]",
+    AC_CAPABILITY_MU_BEAMFORMEE: "[MU-BEAMFORMEE]",
+    AC_CAPABILITY_VHT_TXOP_PS: "[VHT-TXOP-PS]",
+    AC_CAPABILITY_HTC_VHT: "[HTC-VHT]",
+    AC_CAPABILITY_MAX_A_MPDU_LEN_EXP0: "[MAX-A-MPDU-LEN-EXP0]",
+    AC_CAPABILITY_MAX_A_MPDU_LEN_EXP1: "[MAX-A-MPDU-LEN-EXP1]",
+    AC_CAPABILITY_MAX_A_MPDU_LEN_EXP2: "[MAX-A-MPDU-LEN-EXP2]",
+    AC_CAPABILITY_MAX_A_MPDU_LEN_EXP3: "[MAX-A-MPDU-LEN-EXP3]",
+    AC_CAPABILITY_MAX_A_MPDU_LEN_EXP4: "[MAX-A-MPDU-LEN-EXP4]",
+    AC_CAPABILITY_MAX_A_MPDU_LEN_EXP5: "[MAX-A-MPDU-LEN-EXP5]",
+    AC_CAPABILITY_MAX_A_MPDU_LEN_EXP6: "[MAX-A-MPDU-LEN-EXP6]",
+    AC_CAPABILITY_MAX_A_MPDU_LEN_EXP7: "[MAX-A-MPDU-LEN-EXP7]",
+    AC_CAPABILITY_VHT_LINK_ADAPT2: "[VHT-LINK-ADAPT2]",
+    AC_CAPABILITY_VHT_LINK_ADAPT3: "[VHT-LINK-ADAPT3]",
+    AC_CAPABILITY_RX_ANTENNA_PATTERN: "[RX-ANTENNA-PATTERN]",
+    AC_CAPABILITY_TX_ANTENNA_PATTERN: "[TX-ANTENNA-PATTERN]",
+    AC_CAPABILITY_MAX_MPDU_11454: "[MAX-MPDU-11454]",
+    AC_CAPABILITY_MAX_MPDU_7991: "[MAX-MPDU-7991]",
+}
+AC_CAPABILITIES_MAPPING_INVERSE = {v: k for k, v in AC_CAPABILITIES_MAPPING.items()}
+VHT_CHANNEL_WIDTH_40 = 0
+VHT_CHANNEL_WIDTH_80 = 1
+VHT_CHANNEL_WIDTH_160 = 2
+VHT_CHANNEL_WIDTH_80_80 = 3
+
+VHT_CHANNEL = {
+    40: VHT_CHANNEL_WIDTH_40,
+    80: VHT_CHANNEL_WIDTH_80,
+    160: VHT_CHANNEL_WIDTH_160,
+}
+
+# This is a loose merging of the rules for US and EU regulatory
+# domains as taken from IEEE Std 802.11-2012 Appendix E.  For instance,
+# we tolerate HT40 in channels 149-161 (not allowed in EU), but also
+# tolerate HT40+ on channel 7 (not allowed in the US).  We take the loose
+# definition so that we don't prohibit testing in either domain.
+HT40_ALLOW_MAP = {
+    N_CAPABILITY_HT40_MINUS_CHANNELS: tuple(
+        itertools.chain(range(6, 14), range(40, 65, 8), range(104, 145, 8), [153, 161])
+    ),
+    N_CAPABILITY_HT40_PLUS_CHANNELS: tuple(
+        itertools.chain(range(1, 8), range(36, 61, 8), range(100, 141, 8), [149, 157])
+    ),
+}
+
+PMF_SUPPORT_DISABLED = 0
+PMF_SUPPORT_ENABLED = 1
+PMF_SUPPORT_REQUIRED = 2
+PMF_SUPPORT_VALUES = (PMF_SUPPORT_DISABLED, PMF_SUPPORT_ENABLED, PMF_SUPPORT_REQUIRED)
+
+DRIVER_NAME = "nl80211"
+
+
+class VHTChannelWidth(TypedDict):
+    delta: int
+    channels: list[tuple[int, int]]
+
+
+CENTER_CHANNEL_MAP = {
+    VHT_CHANNEL_WIDTH_40: VHTChannelWidth(
+        delta=2,
+        channels=[
+            (36, 40),
+            (44, 48),
+            (52, 56),
+            (60, 64),
+            (100, 104),
+            (108, 112),
+            (116, 120),
+            (124, 128),
+            (132, 136),
+            (140, 144),
+            (149, 153),
+            (157, 161),
+        ],
+    ),
+    VHT_CHANNEL_WIDTH_80: VHTChannelWidth(
+        delta=6,
+        channels=[
+            (36, 48),
+            (52, 64),
+            (100, 112),
+            (116, 128),
+            (132, 144),
+            (149, 161),
+        ],
+    ),
+    VHT_CHANNEL_WIDTH_160: VHTChannelWidth(
+        delta=14,
+        channels=[(36, 64), (100, 128)],
+    ),
+}
+
+OFDM_DATA_RATES = {"supported_rates": "60 90 120 180 240 360 480 540"}
+
+CCK_DATA_RATES = {"supported_rates": "10 20 55 110"}
+
+CCK_AND_OFDM_DATA_RATES = {
+    "supported_rates": "10 20 55 110 60 90 120 180 240 360 480 540"
+}
+
+OFDM_ONLY_BASIC_RATES = {"basic_rates": "60 120 240"}
+
+CCK_AND_OFDM_BASIC_RATES = {"basic_rates": "10 20 55 110"}
+
+WEP_AUTH = {
+    "open": {"auth_algs": 1},
+    "shared": {"auth_algs": 2},
+    "open_and_shared": {"auth_algs": 3},
+}
+
+WMM_11B_DEFAULT_PARAMS = {
+    "wmm_ac_bk_cwmin": 5,
+    "wmm_ac_bk_cwmax": 10,
+    "wmm_ac_bk_aifs": 7,
+    "wmm_ac_bk_txop_limit": 0,
+    "wmm_ac_be_aifs": 3,
+    "wmm_ac_be_cwmin": 5,
+    "wmm_ac_be_cwmax": 7,
+    "wmm_ac_be_txop_limit": 0,
+    "wmm_ac_vi_aifs": 2,
+    "wmm_ac_vi_cwmin": 4,
+    "wmm_ac_vi_cwmax": 5,
+    "wmm_ac_vi_txop_limit": 188,
+    "wmm_ac_vo_aifs": 2,
+    "wmm_ac_vo_cwmin": 3,
+    "wmm_ac_vo_cwmax": 4,
+    "wmm_ac_vo_txop_limit": 102,
+}
+
+WMM_PHYS_11A_11G_11N_11AC_DEFAULT_PARAMS = {
+    "wmm_ac_bk_cwmin": 4,
+    "wmm_ac_bk_cwmax": 10,
+    "wmm_ac_bk_aifs": 7,
+    "wmm_ac_bk_txop_limit": 0,
+    "wmm_ac_be_aifs": 3,
+    "wmm_ac_be_cwmin": 4,
+    "wmm_ac_be_cwmax": 10,
+    "wmm_ac_be_txop_limit": 0,
+    "wmm_ac_vi_aifs": 2,
+    "wmm_ac_vi_cwmin": 3,
+    "wmm_ac_vi_cwmax": 4,
+    "wmm_ac_vi_txop_limit": 94,
+    "wmm_ac_vo_aifs": 2,
+    "wmm_ac_vo_cwmin": 2,
+    "wmm_ac_vo_cwmax": 3,
+    "wmm_ac_vo_txop_limit": 47,
+}
+
+WMM_NON_DEFAULT_PARAMS = {
+    "wmm_ac_bk_cwmin": 5,
+    "wmm_ac_bk_cwmax": 9,
+    "wmm_ac_bk_aifs": 3,
+    "wmm_ac_bk_txop_limit": 94,
+    "wmm_ac_be_aifs": 2,
+    "wmm_ac_be_cwmin": 2,
+    "wmm_ac_be_cwmax": 8,
+    "wmm_ac_be_txop_limit": 0,
+    "wmm_ac_vi_aifs": 1,
+    "wmm_ac_vi_cwmin": 7,
+    "wmm_ac_vi_cwmax": 10,
+    "wmm_ac_vi_txop_limit": 47,
+    "wmm_ac_vo_aifs": 1,
+    "wmm_ac_vo_cwmin": 6,
+    "wmm_ac_vo_cwmax": 10,
+    "wmm_ac_vo_txop_limit": 94,
+}
+
+WMM_DEGRADED_VO_PARAMS = {
+    "wmm_ac_bk_cwmin": 7,
+    "wmm_ac_bk_cwmax": 15,
+    "wmm_ac_bk_aifs": 2,
+    "wmm_ac_bk_txop_limit": 0,
+    "wmm_ac_be_aifs": 2,
+    "wmm_ac_be_cwmin": 7,
+    "wmm_ac_be_cwmax": 15,
+    "wmm_ac_be_txop_limit": 0,
+    "wmm_ac_vi_aifs": 2,
+    "wmm_ac_vi_cwmin": 7,
+    "wmm_ac_vi_cwmax": 15,
+    "wmm_ac_vi_txop_limit": 94,
+    "wmm_ac_vo_aifs": 10,
+    "wmm_ac_vo_cwmin": 7,
+    "wmm_ac_vo_cwmax": 15,
+    "wmm_ac_vo_txop_limit": 47,
+}
+
+WMM_DEGRADED_VI_PARAMS = {
+    "wmm_ac_bk_cwmin": 7,
+    "wmm_ac_bk_cwmax": 15,
+    "wmm_ac_bk_aifs": 2,
+    "wmm_ac_bk_txop_limit": 0,
+    "wmm_ac_be_aifs": 2,
+    "wmm_ac_be_cwmin": 7,
+    "wmm_ac_be_cwmax": 15,
+    "wmm_ac_be_txop_limit": 0,
+    "wmm_ac_vi_aifs": 10,
+    "wmm_ac_vi_cwmin": 7,
+    "wmm_ac_vi_cwmax": 15,
+    "wmm_ac_vi_txop_limit": 94,
+    "wmm_ac_vo_aifs": 2,
+    "wmm_ac_vo_cwmin": 7,
+    "wmm_ac_vo_cwmax": 15,
+    "wmm_ac_vo_txop_limit": 47,
+}
+
+WMM_IMPROVE_BE_PARAMS = {
+    "wmm_ac_bk_cwmin": 7,
+    "wmm_ac_bk_cwmax": 15,
+    "wmm_ac_bk_aifs": 10,
+    "wmm_ac_bk_txop_limit": 0,
+    "wmm_ac_be_aifs": 2,
+    "wmm_ac_be_cwmin": 7,
+    "wmm_ac_be_cwmax": 15,
+    "wmm_ac_be_txop_limit": 0,
+    "wmm_ac_vi_aifs": 10,
+    "wmm_ac_vi_cwmin": 7,
+    "wmm_ac_vi_cwmax": 15,
+    "wmm_ac_vi_txop_limit": 94,
+    "wmm_ac_vo_aifs": 10,
+    "wmm_ac_vo_cwmin": 7,
+    "wmm_ac_vo_cwmax": 15,
+    "wmm_ac_vo_txop_limit": 47,
+}
+
+WMM_IMPROVE_BK_PARAMS = {
+    "wmm_ac_bk_cwmin": 7,
+    "wmm_ac_bk_cwmax": 15,
+    "wmm_ac_bk_aifs": 2,
+    "wmm_ac_bk_txop_limit": 0,
+    "wmm_ac_be_aifs": 10,
+    "wmm_ac_be_cwmin": 7,
+    "wmm_ac_be_cwmax": 15,
+    "wmm_ac_be_txop_limit": 0,
+    "wmm_ac_vi_aifs": 10,
+    "wmm_ac_vi_cwmin": 7,
+    "wmm_ac_vi_cwmax": 15,
+    "wmm_ac_vi_txop_limit": 94,
+    "wmm_ac_vo_aifs": 10,
+    "wmm_ac_vo_cwmin": 7,
+    "wmm_ac_vo_cwmax": 15,
+    "wmm_ac_vo_txop_limit": 47,
+}
+
+WMM_ACM_BK = {"wmm_ac_bk_acm": 1}
+WMM_ACM_BE = {"wmm_ac_be_acm": 1}
+WMM_ACM_VI = {"wmm_ac_vi_acm": 1}
+WMM_ACM_VO = {"wmm_ac_vo_acm": 1}
+
+UAPSD_ENABLED = {"uapsd_advertisement_enabled": 1}
+
+UTF_8_SSID = {"utf8_ssid": 1}
+
+ENABLE_RRM_BEACON_REPORT = {"rrm_beacon_report": 1}
+ENABLE_RRM_NEIGHBOR_REPORT = {"rrm_neighbor_report": 1}
+
+# Wireless Network Management (AKA 802.11v) features.
+ENABLE_WNM_TIME_ADVERTISEMENT = {"time_advertisement": 2, "time_zone": "EST5"}
+ENABLE_WNM_SLEEP_MODE = {"wnm_sleep_mode": 1}
+ENABLE_WNM_BSS_TRANSITION_MANAGEMENT = {"bss_transition": 1}
+ENABLE_WNM_PROXY_ARP = {"proxy_arp": 1}
+ENABLE_WNM_IPV6_NEIGHBOR_ADVERTISEMENT_MULTICAST_TO_UNICAST = {"na_mcast_to_ucast": 1}
+
+VENDOR_IE = {
+    "correct_length_beacon": {"vendor_elements": "dd0411223301"},
+    "too_short_length_beacon": {"vendor_elements": "dd0311223301"},
+    "too_long_length_beacon": {"vendor_elements": "dd0511223301"},
+    "zero_length_beacon_with_data": {"vendor_elements": "dd0011223301"},
+    "zero_length_beacon_without_data": {"vendor_elements": "dd00"},
+    "simliar_to_wpa": {"vendor_elements": "dd040050f203"},
+    "correct_length_association_response": {"assocresp_elements": "dd0411223301"},
+    "too_short_length_association_response": {"assocresp_elements": "dd0311223301"},
+    "too_long_length_association_response": {"assocresp_elements": "dd0511223301"},
+    "zero_length_association_response_with_data": {
+        "assocresp_elements": "dd0011223301"
+    },
+    "zero_length_association_response_without_data": {"assocresp_elements": "dd00"},
+}
+
+ENABLE_IEEE80211D = {"ieee80211d": 1}
+
+COUNTRY_STRING = {
+    "ALL": {"country3": "0x20"},
+    "OUTDOOR": {"country3": "0x4f"},
+    "INDOOR": {"country3": "0x49"},
+    "NONCOUNTRY": {"country3": "0x58"},
+    "GLOBAL": {"country3": "0x04"},
+}
+
+COUNTRY_CODE = {
+    "AFGHANISTAN": {"country_code": "AF"},
+    "ALAND_ISLANDS": {"country_code": "AX"},
+    "ALBANIA": {"country_code": "AL"},
+    "ALGERIA": {"country_code": "DZ"},
+    "AMERICAN_SAMOA": {"country_code": "AS"},
+    "ANDORRA": {"country_code": "AD"},
+    "ANGOLA": {"country_code": "AO"},
+    "ANGUILLA": {"country_code": "AI"},
+    "ANTARCTICA": {"country_code": "AQ"},
+    "ANTIGUA_AND_BARBUDA": {"country_code": "AG"},
+    "ARGENTINA": {"country_code": "AR"},
+    "ARMENIA": {"country_code": "AM"},
+    "ARUBA": {"country_code": "AW"},
+    "AUSTRALIA": {"country_code": "AU"},
+    "AUSTRIA": {"country_code": "AT"},
+    "AZERBAIJAN": {"country_code": "AZ"},
+    "BAHAMAS": {"country_code": "BS"},
+    "BAHRAIN": {"country_code": "BH"},
+    "BANGLADESH": {"country_code": "BD"},
+    "BARBADOS": {"country_code": "BB"},
+    "BELARUS": {"country_code": "BY"},
+    "BELGIUM": {"country_code": "BE"},
+    "BELIZE": {"country_code": "BZ"},
+    "BENIN": {"country_code": "BJ"},
+    "BERMUDA": {"country_code": "BM"},
+    "BHUTAN": {"country_code": "BT"},
+    "BOLIVIA": {"country_code": "BO"},
+    "BONAIRE": {"country_code": "BQ"},
+    "BOSNIA_AND_HERZEGOVINA": {"country_code": "BA"},
+    "BOTSWANA": {"country_code": "BW"},
+    "BOUVET_ISLAND": {"country_code": "BV"},
+    "BRAZIL": {"country_code": "BR"},
+    "BRITISH_INDIAN_OCEAN_TERRITORY": {"country_code": "IO"},
+    "BRUNEI_DARUSSALAM": {"country_code": "BN"},
+    "BULGARIA": {"country_code": "BG"},
+    "BURKINA_FASO": {"country_code": "BF"},
+    "BURUNDI": {"country_code": "BI"},
+    "CAMBODIA": {"country_code": "KH"},
+    "CAMEROON": {"country_code": "CM"},
+    "CANADA": {"country_code": "CA"},
+    "CAPE_VERDE": {"country_code": "CV"},
+    "CAYMAN_ISLANDS": {"country_code": "KY"},
+    "CENTRAL_AFRICAN_REPUBLIC": {"country_code": "CF"},
+    "CHAD": {"country_code": "TD"},
+    "CHILE": {"country_code": "CL"},
+    "CHINA": {"country_code": "CN"},
+    "CHRISTMAS_ISLAND": {"country_code": "CX"},
+    "COCOS_ISLANDS": {"country_code": "CC"},
+    "COLOMBIA": {"country_code": "CO"},
+    "COMOROS": {"country_code": "KM"},
+    "CONGO": {"country_code": "CG"},
+    "DEMOCRATIC_REPUBLIC_CONGO": {"country_code": "CD"},
+    "COOK_ISLANDS": {"country_code": "CK"},
+    "COSTA_RICA": {"country_code": "CR"},
+    "COTE_D_IVOIRE": {"country_code": "CI"},
+    "CROATIA": {"country_code": "HR"},
+    "CUBA": {"country_code": "CU"},
+    "CURACAO": {"country_code": "CW"},
+    "CYPRUS": {"country_code": "CY"},
+    "CZECH_REPUBLIC": {"country_code": "CZ"},
+    "DENMARK": {"country_code": "DK"},
+    "DJIBOUTI": {"country_code": "DJ"},
+    "DOMINICA": {"country_code": "DM"},
+    "DOMINICAN_REPUBLIC": {"country_code": "DO"},
+    "ECUADOR": {"country_code": "EC"},
+    "EGYPT": {"country_code": "EG"},
+    "EL_SALVADOR": {"country_code": "SV"},
+    "EQUATORIAL_GUINEA": {"country_code": "GQ"},
+    "ERITREA": {"country_code": "ER"},
+    "ESTONIA": {"country_code": "EE"},
+    "ETHIOPIA": {"country_code": "ET"},
+    "FALKLAND_ISLANDS_(MALVINAS)": {"country_code": "FK"},
+    "FAROE_ISLANDS": {"country_code": "FO"},
+    "FIJI": {"country_code": "FJ"},
+    "FINLAND": {"country_code": "FI"},
+    "FRANCE": {"country_code": "FR"},
+    "FRENCH_GUIANA": {"country_code": "GF"},
+    "FRENCH_POLYNESIA": {"country_code": "PF"},
+    "FRENCH_SOUTHERN_TERRITORIES": {"country_code": "TF"},
+    "GABON": {"country_code": "GA"},
+    "GAMBIA": {"country_code": "GM"},
+    "GEORGIA": {"country_code": "GE"},
+    "GERMANY": {"country_code": "DE"},
+    "GHANA": {"country_code": "GH"},
+    "GIBRALTAR": {"country_code": "GI"},
+    "GREECE": {"country_code": "GR"},
+    "GREENLAND": {"country_code": "GL"},
+    "GRENADA": {"country_code": "GD"},
+    "GUADELOUPE": {"country_code": "GP"},
+    "GUAM": {"country_code": "GU"},
+    "GUATEMALA": {"country_code": "GT"},
+    "GUERNSEY": {"country_code": "GG"},
+    "GUINEA": {"country_code": "GN"},
+    "GUINEA-BISSAU": {"country_code": "GW"},
+    "GUYANA": {"country_code": "GY"},
+    "HAITI": {"country_code": "HT"},
+    "HEARD_ISLAND_AND_MCDONALD_ISLANDS": {"country_code": "HM"},
+    "VATICAN_CITY_STATE": {"country_code": "VA"},
+    "HONDURAS": {"country_code": "HN"},
+    "HONG_KONG": {"country_code": "HK"},
+    "HUNGARY": {"country_code": "HU"},
+    "ICELAND": {"country_code": "IS"},
+    "INDIA": {"country_code": "IN"},
+    "INDONESIA": {"country_code": "ID"},
+    "IRAN": {"country_code": "IR"},
+    "IRAQ": {"country_code": "IQ"},
+    "IRELAND": {"country_code": "IE"},
+    "ISLE_OF_MAN": {"country_code": "IM"},
+    "ISRAEL": {"country_code": "IL"},
+    "ITALY": {"country_code": "IT"},
+    "JAMAICA": {"country_code": "JM"},
+    "JAPAN": {"country_code": "JP"},
+    "JERSEY": {"country_code": "JE"},
+    "JORDAN": {"country_code": "JO"},
+    "KAZAKHSTAN": {"country_code": "KZ"},
+    "KENYA": {"country_code": "KE"},
+    "KIRIBATI": {"country_code": "KI"},
+    "DEMOCRATIC_PEOPLE_S_REPUBLIC_OF_KOREA": {"country_code": "KP"},
+    "REPUBLIC_OF_KOREA": {"country_code": "KR"},
+    "KUWAIT": {"country_code": "KW"},
+    "KYRGYZSTAN": {"country_code": "KG"},
+    "LAO": {"country_code": "LA"},
+    "LATVIA": {"country_code": "LV"},
+    "LEBANON": {"country_code": "LB"},
+    "LESOTHO": {"country_code": "LS"},
+    "LIBERIA": {"country_code": "LR"},
+    "LIBYA": {"country_code": "LY"},
+    "LIECHTENSTEIN": {"country_code": "LI"},
+    "LITHUANIA": {"country_code": "LT"},
+    "LUXEMBOURG": {"country_code": "LU"},
+    "MACAO": {"country_code": "MO"},
+    "MACEDONIA": {"country_code": "MK"},
+    "MADAGASCAR": {"country_code": "MG"},
+    "MALAWI": {"country_code": "MW"},
+    "MALAYSIA": {"country_code": "MY"},
+    "MALDIVES": {"country_code": "MV"},
+    "MALI": {"country_code": "ML"},
+    "MALTA": {"country_code": "MT"},
+    "MARSHALL_ISLANDS": {"country_code": "MH"},
+    "MARTINIQUE": {"country_code": "MQ"},
+    "MAURITANIA": {"country_code": "MR"},
+    "MAURITIUS": {"country_code": "MU"},
+    "MAYOTTE": {"country_code": "YT"},
+    "MEXICO": {"country_code": "MX"},
+    "MICRONESIA": {"country_code": "FM"},
+    "MOLDOVA": {"country_code": "MD"},
+    "MONACO": {"country_code": "MC"},
+    "MONGOLIA": {"country_code": "MN"},
+    "MONTENEGRO": {"country_code": "ME"},
+    "MONTSERRAT": {"country_code": "MS"},
+    "MOROCCO": {"country_code": "MA"},
+    "MOZAMBIQUE": {"country_code": "MZ"},
+    "MYANMAR": {"country_code": "MM"},
+    "NAMIBIA": {"country_code": "NA"},
+    "NAURU": {"country_code": "NR"},
+    "NEPAL": {"country_code": "NP"},
+    "NETHERLANDS": {"country_code": "NL"},
+    "NEW_CALEDONIA": {"country_code": "NC"},
+    "NEW_ZEALAND": {"country_code": "NZ"},
+    "NICARAGUA": {"country_code": "NI"},
+    "NIGER": {"country_code": "NE"},
+    "NIGERIA": {"country_code": "NG"},
+    "NIUE": {"country_code": "NU"},
+    "NORFOLK_ISLAND": {"country_code": "NF"},
+    "NORTHERN_MARIANA_ISLANDS": {"country_code": "MP"},
+    "NORWAY": {"country_code": "NO"},
+    "OMAN": {"country_code": "OM"},
+    "PAKISTAN": {"country_code": "PK"},
+    "PALAU": {"country_code": "PW"},
+    "PALESTINE": {"country_code": "PS"},
+    "PANAMA": {"country_code": "PA"},
+    "PAPUA_NEW_GUINEA": {"country_code": "PG"},
+    "PARAGUAY": {"country_code": "PY"},
+    "PERU": {"country_code": "PE"},
+    "PHILIPPINES": {"country_code": "PH"},
+    "PITCAIRN": {"country_code": "PN"},
+    "POLAND": {"country_code": "PL"},
+    "PORTUGAL": {"country_code": "PT"},
+    "PUERTO_RICO": {"country_code": "PR"},
+    "QATAR": {"country_code": "QA"},
+    "RÉUNION": {"country_code": "RE"},
+    "ROMANIA": {"country_code": "RO"},
+    "RUSSIAN_FEDERATION": {"country_code": "RU"},
+    "RWANDA": {"country_code": "RW"},
+    "SAINT_BARTHELEMY": {"country_code": "BL"},
+    "SAINT_KITTS_AND_NEVIS": {"country_code": "KN"},
+    "SAINT_LUCIA": {"country_code": "LC"},
+    "SAINT_MARTIN": {"country_code": "MF"},
+    "SAINT_PIERRE_AND_MIQUELON": {"country_code": "PM"},
+    "SAINT_VINCENT_AND_THE_GRENADINES": {"country_code": "VC"},
+    "SAMOA": {"country_code": "WS"},
+    "SAN_MARINO": {"country_code": "SM"},
+    "SAO_TOME_AND_PRINCIPE": {"country_code": "ST"},
+    "SAUDI_ARABIA": {"country_code": "SA"},
+    "SENEGAL": {"country_code": "SN"},
+    "SERBIA": {"country_code": "RS"},
+    "SEYCHELLES": {"country_code": "SC"},
+    "SIERRA_LEONE": {"country_code": "SL"},
+    "SINGAPORE": {"country_code": "SG"},
+    "SINT_MAARTEN": {"country_code": "SX"},
+    "SLOVAKIA": {"country_code": "SK"},
+    "SLOVENIA": {"country_code": "SI"},
+    "SOLOMON_ISLANDS": {"country_code": "SB"},
+    "SOMALIA": {"country_code": "SO"},
+    "SOUTH_AFRICA": {"country_code": "ZA"},
+    "SOUTH_GEORGIA": {"country_code": "GS"},
+    "SOUTH_SUDAN": {"country_code": "SS"},
+    "SPAIN": {"country_code": "ES"},
+    "SRI_LANKA": {"country_code": "LK"},
+    "SUDAN": {"country_code": "SD"},
+    "SURINAME": {"country_code": "SR"},
+    "SVALBARD_AND_JAN_MAYEN": {"country_code": "SJ"},
+    "SWAZILAND": {"country_code": "SZ"},
+    "SWEDEN": {"country_code": "SE"},
+    "SWITZERLAND": {"country_code": "CH"},
+    "SYRIAN_ARAB_REPUBLIC": {"country_code": "SY"},
+    "TAIWAN": {"country_code": "TW"},
+    "TAJIKISTAN": {"country_code": "TJ"},
+    "TANZANIA": {"country_code": "TZ"},
+    "THAILAND": {"country_code": "TH"},
+    "TIMOR-LESTE": {"country_code": "TL"},
+    "TOGO": {"country_code": "TG"},
+    "TOKELAU": {"country_code": "TK"},
+    "TONGA": {"country_code": "TO"},
+    "TRINIDAD_AND_TOBAGO": {"country_code": "TT"},
+    "TUNISIA": {"country_code": "TN"},
+    "TURKEY": {"country_code": "TR"},
+    "TURKMENISTAN": {"country_code": "TM"},
+    "TURKS_AND_CAICOS_ISLANDS": {"country_code": "TC"},
+    "TUVALU": {"country_code": "TV"},
+    "UGANDA": {"country_code": "UG"},
+    "UKRAINE": {"country_code": "UA"},
+    "UNITED_ARAB_EMIRATES": {"country_code": "AE"},
+    "UNITED_KINGDOM": {"country_code": "GB"},
+    "UNITED_STATES": {"country_code": "US"},
+    "UNITED_STATES_MINOR_OUTLYING_ISLANDS": {"country_code": "UM"},
+    "URUGUAY": {"country_code": "UY"},
+    "UZBEKISTAN": {"country_code": "UZ"},
+    "VANUATU": {"country_code": "VU"},
+    "VENEZUELA": {"country_code": "VE"},
+    "VIETNAM": {"country_code": "VN"},
+    "VIRGIN_ISLANDS_BRITISH": {"country_code": "VG"},
+    "VIRGIN_ISLANDS_US": {"country_code": "VI"},
+    "WALLIS_AND_FUTUNA": {"country_code": "WF"},
+    "WESTERN_SAHARA": {"country_code": "EH"},
+    "YEMEN": {"country_code": "YE"},
+    "ZAMBIA": {"country_code": "ZM"},
+    "ZIMBABWE": {"country_code": "ZW"},
+    "NON_COUNTRY": {"country_code": "XX"},
+}
+
+ALL_CHANNELS_2G = {
+    1: {20, 40},
+    2: {20, 40},
+    3: {20, 40},
+    4: {20, 40},
+    5: {20, 40},
+    6: {20, 40},
+    7: {20, 40},
+    8: {20, 40},
+    9: {20, 40},
+    10: {20, 40},
+    11: {20, 40},
+    12: {20, 40},
+    13: {20, 40},
+    14: {20},
+}
+
+ALL_CHANNELS_5G = {
+    36: {20, 40, 80},
+    40: {20, 40, 80},
+    44: {20, 40, 80},
+    48: {20, 40, 80},
+    52: {20, 40, 80},
+    56: {20, 40, 80},
+    60: {20, 40, 80},
+    64: {20, 40, 80},
+    100: {20, 40, 80},
+    104: {20, 40, 80},
+    108: {20, 40, 80},
+    112: {20, 40, 80},
+    116: {20, 40, 80},
+    120: {20, 40, 80},
+    124: {20, 40, 80},
+    128: {20, 40, 80},
+    132: {20, 40, 80},
+    136: {20, 40, 80},
+    140: {20, 40, 80},
+    144: {20, 40, 80},
+    149: {20, 40, 80},
+    153: {20, 40, 80},
+    157: {20, 40, 80},
+    161: {20, 40, 80},
+    165: {20},
+}
+
+ALL_CHANNELS = ALL_CHANNELS_2G | ALL_CHANNELS_5G
+
+
+@unique
+class WnmFeature(Enum):
+    """Wireless Network Management (AKA 802.11v) features hostapd supports."""
+
+    TIME_ADVERTISEMENT = auto()
+    WNM_SLEEP_MODE = auto()
+    BSS_TRANSITION_MANAGEMENT = auto()
+    PROXY_ARP = auto()
+    IPV6_NEIGHBOR_ADVERTISEMENT_MULTICAST_TO_UNICAST = auto()
diff --git a/packages/antlion/controllers/ap_lib/hostapd_security.py b/packages/antlion/controllers/ap_lib/hostapd_security.py
new file mode 100644
index 0000000..fe1d41c
--- /dev/null
+++ b/packages/antlion/controllers/ap_lib/hostapd_security.py
@@ -0,0 +1,453 @@
+# 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 collections
+import string
+from enum import Enum, StrEnum, auto, unique
+
+from honeydew.affordances.connectivity.wlan.utils.types import SecurityProtocol
+
+from antlion.controllers.ap_lib import hostapd_constants
+
+
+class SecurityModeInt(int, Enum):
+    """Possible values for hostapd's "wpa" config option.
+
+    The int value is a bit field that can enable WPA and/or WPA2.
+
+    bit0 = enable WPA defined by IEEE 802.11i/D3.0
+    bit1 = enable RNA (WPA2) defined by IEEE 802.11i/RSN
+    bit2 = enable WAPI (rejected/withdrawn)
+    bit3 = enable OSEN (ENT)
+    """
+
+    WEP = 0
+    WPA1 = 1
+    WPA2 = 2
+    WPA3 = 2  # same as wpa2 and wpa2/wpa3; distinguished by wpa_key_mgmt
+    MIXED = 3  # applies to wpa/wpa2 and wpa/wpa2/wpa3; distinguished by wpa_key_mgmt
+    ENT = 8
+
+    def __str__(self):
+        return str(self.value)
+
+
+@unique
+class KeyManagement(StrEnum):
+    SAE = "SAE"
+    WPA_PSK = "WPA-PSK"
+    WPA_PSK_SAE = "WPA-PSK SAE"
+    ENT = "WPA-EAP"
+
+
+# TODO(http://b/286584981): This is currently only being used for OpenWRT.
+# Investigate whether we can replace KeyManagement with OpenWRTEncryptionMode.
+@unique
+class OpenWRTEncryptionMode(StrEnum):
+    """Combination of Wi-Fi encryption mode and ciphers.
+
+    Only used by OpenWRT.
+
+    Besides the encryption mode, the encryption option also specifies the group and peer
+    ciphers to use. To override the cipher, the value of encryption must be given in the
+    form "mode+cipher". This enum contains all possible combinations.
+
+    See https://openwrt.org/docs/guide-user/network/wifi/basic#encryption_modes.
+    """
+
+    NONE = "none"
+    """No authentication, no ciphers"""
+    SAE = "sae"
+    """WPA3 Personal (SAE) using CCMP cipher"""
+    SAE_MIXED = "sae-mixed"
+    """WPA2/WPA3 Personal (PSK/SAE) mixed mode using CCMP cipher"""
+    PSK2_TKIP_CCMP = "psk2+tkip+ccmp"
+    """WPA2 Personal (PSK) using TKIP and CCMP ciphers"""
+    PSK2_TKIP_AES = "psk2+tkip+aes"
+    """WPA2 Personal (PSK) using TKIP and AES ciphers"""
+    PSK2_TKIP = "psk2+tkip"
+    """WPA2 Personal (PSK) using TKIP cipher"""
+    PSK2_CCMP = "psk2+ccmp"
+    """WPA2 Personal (PSK) using CCMP cipher"""
+    PSK2_AES = "psk2+aes"
+    """WPA2 Personal (PSK) using AES cipher"""
+    PSK2 = "psk2"
+    """WPA2 Personal (PSK) using CCMP cipher"""
+    PSK_TKIP_CCMP = "psk+tkip+ccmp"
+    """WPA Personal (PSK) using TKIP and CCMP ciphers"""
+    PSK_TKIP_AES = "psk+tkip+aes"
+    """WPA Personal (PSK) using TKIP and AES ciphers"""
+    PSK_TKIP = "psk+tkip"
+    """WPA Personal (PSK) using TKIP cipher"""
+    PSK_CCMP = "psk+ccmp"
+    """WPA Personal (PSK) using CCMP cipher"""
+    PSK_AES = "psk+aes"
+    """WPA Personal (PSK) using AES cipher"""
+    PSK = "psk"
+    """WPA Personal (PSK) using CCMP cipher"""
+    PSK_MIXED_TKIP_CCMP = "psk-mixed+tkip+ccmp"
+    """WPA/WPA2 Personal (PSK) mixed mode using TKIP and CCMP ciphers"""
+    PSK_MIXED_TKIP_AES = "psk-mixed+tkip+aes"
+    """WPA/WPA2 Personal (PSK) mixed mode using TKIP and AES ciphers"""
+    PSK_MIXED_TKIP = "psk-mixed+tkip"
+    """WPA/WPA2 Personal (PSK) mixed mode using TKIP cipher"""
+    PSK_MIXED_CCMP = "psk-mixed+ccmp"
+    """WPA/WPA2 Personal (PSK) mixed mode using CCMP cipher"""
+    PSK_MIXED_AES = "psk-mixed+aes"
+    """WPA/WPA2 Personal (PSK) mixed mode using AES cipher"""
+    PSK_MIXED = "psk-mixed"
+    """WPA/WPA2 Personal (PSK) mixed mode using CCMP cipher"""
+    WEP = "wep"
+    """defaults to “open system” authentication aka wep+open using RC4 cipher"""
+    WEP_OPEN = "wep+open"
+    """“open system” authentication using RC4 cipher"""
+    WEP_SHARED = "wep+shared"
+    """“shared key” authentication using RC4 cipher"""
+    WPA3 = "wpa3"
+    """WPA3 Enterprise using CCMP cipher"""
+    WPA3_MIXED = "wpa3-mixed"
+    """WPA3/WPA2 Enterprise using CCMP cipher"""
+    WPA2_TKIP_CCMP = "wpa2+tkip+ccmp"
+    """WPA2 Enterprise using TKIP and CCMP ciphers"""
+    WPA2_TKIP_AES = "wpa2+tkip+aes"
+    """WPA2 Enterprise using TKIP and AES ciphers"""
+    WPA2_CCMP = "wpa2+ccmp"
+    """WPA2 Enterprise using CCMP cipher"""
+    WPA2_AES = "wpa2+aes'"
+    """WPA2 Enterprise using AES cipher"""
+    WPA2 = "wpa2"
+    """WPA2 Enterprise using CCMP cipher"""
+    WPA2_TKIP = "wpa2+tkip"
+    """WPA2 Enterprise using TKIP cipher"""
+    WPA_TKIP_CCMP = "wpa+tkip+ccmp"
+    """WPA Enterprise using TKIP and CCMP ciphers"""
+    WPA_TKIP_AES = "wpa+tkip+aes"
+    """WPA Enterprise using TKIP and AES ciphers"""
+    WPA_CCMP = "wpa+ccmp"
+    """WPA Enterprise using CCMP cipher"""
+    WPA_AES = "wpa+aes"
+    """WPA Enterprise using AES cipher"""
+    WPA_TKIP = "wpa+tkip"
+    """WPA Enterprise using TKIP cipher"""
+    WPA = "wpa"
+    """WPA Enterprise using CCMP cipher"""
+    WPA_MIXED_TKIP_CCMP = "wpa-mixed+tkip+ccmp"
+    """WPA/WPA2 Enterprise mixed mode using TKIP and CCMP ciphers"""
+    WPA_MIXED_TKIP_AES = "wpa-mixed+tkip+aes"
+    """WPA/WPA2 Enterprise mixed mode using TKIP and AES ciphers"""
+    WPA_MIXED_TKIP = "wpa-mixed+tkip"
+    """WPA/WPA2 Enterprise mixed mode using TKIP cipher"""
+    WPA_MIXED_CCMP = "wpa-mixed+ccmp"
+    """WPA/WPA2 Enterprise mixed mode using CCMP cipher"""
+    WPA_MIXED_AES = "wpa-mixed+aes"
+    """WPA/WPA2 Enterprise mixed mode using AES cipher"""
+    WPA_MIXED = "wpa-mixed"
+    """WPA/WPA2 Enterprise mixed mode using CCMP cipher"""
+    OWE = "owe"
+    """Opportunistic Wireless Encryption (OWE) using CCMP cipher"""
+
+
+@unique
+class FuchsiaSecurityType(StrEnum):
+    """Fuchsia supported security types.
+
+    Defined by the fuchsia.wlan.policy.SecurityType FIDL.
+
+    https://cs.opensource.google/fuchsia/fuchsia/+/main:sdk/fidl/fuchsia.wlan.policy/types.fidl
+    """
+
+    NONE = "none"
+    WEP = "wep"
+    WPA = "wpa"
+    WPA2 = "wpa2"
+    WPA3 = "wpa3"
+
+
+@unique
+class SecurityMode(StrEnum):
+    OPEN = auto()
+    WEP = auto()
+    WPA = auto()
+    WPA2 = auto()
+    WPA_WPA2 = auto()
+    WPA3 = auto()
+    WPA2_WPA3 = auto()
+    WPA_WPA2_WPA3 = auto()
+    ENT = auto()
+
+    def security_mode_int(self) -> SecurityModeInt:
+        match self:
+            case SecurityMode.OPEN:
+                raise TypeError("Open security doesn't have a SecurityModeInt")
+            case SecurityMode.WEP:
+                return SecurityModeInt.WEP
+            case SecurityMode.WPA:
+                return SecurityModeInt.WPA1
+            case SecurityMode.WPA2:
+                return SecurityModeInt.WPA2
+            case SecurityMode.WPA_WPA2:
+                return SecurityModeInt.MIXED
+            case SecurityMode.WPA3:
+                return SecurityModeInt.WPA3
+            case SecurityMode.WPA2_WPA3:
+                return SecurityModeInt.WPA3
+            case SecurityMode.WPA_WPA2_WPA3:
+                return SecurityModeInt.MIXED
+            case SecurityMode.ENT:
+                return SecurityModeInt.ENT
+
+    def key_management(self) -> KeyManagement | None:
+        match self:
+            case SecurityMode.OPEN:
+                return None
+            case SecurityMode.WEP:
+                return None
+            case SecurityMode.WPA:
+                return KeyManagement.WPA_PSK
+            case SecurityMode.WPA2:
+                return KeyManagement.WPA_PSK
+            case SecurityMode.WPA_WPA2:
+                return KeyManagement.WPA_PSK
+            case SecurityMode.WPA3:
+                return KeyManagement.SAE
+            case SecurityMode.WPA2_WPA3:
+                return KeyManagement.WPA_PSK_SAE
+            case SecurityMode.WPA_WPA2_WPA3:
+                return KeyManagement.WPA_PSK_SAE
+            case SecurityMode.ENT:
+                return KeyManagement.ENT
+
+    def fuchsia_security_type(self) -> FuchsiaSecurityType:
+        match self:
+            case SecurityMode.OPEN:
+                return FuchsiaSecurityType.NONE
+            case SecurityMode.WEP:
+                return FuchsiaSecurityType.WEP
+            case SecurityMode.WPA:
+                return FuchsiaSecurityType.WPA
+            case SecurityMode.WPA2:
+                return FuchsiaSecurityType.WPA2
+            case SecurityMode.WPA_WPA2:
+                return FuchsiaSecurityType.WPA2
+            case SecurityMode.WPA3:
+                return FuchsiaSecurityType.WPA3
+            case SecurityMode.WPA2_WPA3:
+                return FuchsiaSecurityType.WPA3
+            case SecurityMode.WPA_WPA2_WPA3:
+                return FuchsiaSecurityType.WPA3
+            case SecurityMode.ENT:
+                raise NotImplementedError(
+                    f'Fuchsia has not implemented support for security mode "{self}"'
+                )
+
+    def is_wpa3(self) -> bool:
+        match self:
+            case SecurityMode.OPEN:
+                return False
+            case SecurityMode.WEP:
+                return False
+            case SecurityMode.WPA:
+                return False
+            case SecurityMode.WPA2:
+                return False
+            case SecurityMode.WPA_WPA2:
+                return False
+            case SecurityMode.WPA3:
+                return True
+            case SecurityMode.WPA2_WPA3:
+                return True
+            case SecurityMode.WPA_WPA2_WPA3:
+                return True
+            case SecurityMode.ENT:
+                return False
+        raise TypeError("Unknown security mode")
+
+    def protocol(self, enterprise: bool = False) -> SecurityProtocol:
+        match self:
+            case SecurityMode.OPEN:
+                return SecurityProtocol.OPEN
+            case SecurityMode.WEP:
+                return SecurityProtocol.WEP
+            case SecurityMode.WPA:
+                return SecurityProtocol.WPA1
+            case SecurityMode.WPA2:
+                return (
+                    SecurityProtocol.WPA2_ENTERPRISE
+                    if enterprise
+                    else SecurityProtocol.WPA2_PERSONAL
+                )
+            case SecurityMode.WPA_WPA2:
+                return (
+                    SecurityProtocol.WPA2_ENTERPRISE
+                    if enterprise
+                    else SecurityProtocol.WPA2_PERSONAL
+                )
+            case SecurityMode.WPA3:
+                return (
+                    SecurityProtocol.WPA3_ENTERPRISE
+                    if enterprise
+                    else SecurityProtocol.WPA3_PERSONAL
+                )
+            case SecurityMode.WPA2_WPA3:
+                return (
+                    SecurityProtocol.WPA3_ENTERPRISE
+                    if enterprise
+                    else SecurityProtocol.WPA3_PERSONAL
+                )
+            case SecurityMode.WPA_WPA2_WPA3:
+                return (
+                    SecurityProtocol.WPA3_ENTERPRISE
+                    if enterprise
+                    else SecurityProtocol.WPA3_PERSONAL
+                )
+            case SecurityMode.ENT:
+                raise NotImplementedError(
+                    f'Fuchsia has not implemented support for security mode "{self}"'
+                )
+
+
+class Security(object):
+    """The Security class for hostapd representing some of the security
+    settings that are allowed in hostapd.  If needed more can be added.
+    """
+
+    def __init__(
+        self,
+        security_mode: SecurityMode = SecurityMode.OPEN,
+        password: str | None = None,
+        wpa_cipher: str | None = hostapd_constants.WPA_DEFAULT_CIPHER,
+        wpa2_cipher: str | None = hostapd_constants.WPA2_DEFAULT_CIPER,
+        wpa_group_rekey: int = hostapd_constants.WPA_GROUP_KEY_ROTATION_TIME,
+        wpa_strict_rekey: bool = hostapd_constants.WPA_STRICT_REKEY_DEFAULT,
+        wep_default_key: int = hostapd_constants.WEP_DEFAULT_KEY,
+        radius_server_ip: str | None = None,
+        radius_server_port: int | None = None,
+        radius_server_secret: str | None = None,
+    ) -> None:
+        """Gather all of the security settings for WPA-PSK.  This could be
+           expanded later.
+
+        Args:
+            security_mode: Type of security mode.
+            password: The PSK or passphrase for the security mode.
+            wpa_cipher: The cipher to be used for wpa.
+                        Options: TKIP, CCMP, TKIP CCMP
+                        Default: TKIP
+            wpa2_cipher: The cipher to be used for wpa2.
+                         Options: TKIP, CCMP, TKIP CCMP
+                         Default: CCMP
+            wpa_group_rekey: How often to refresh the GTK regardless of network
+                             changes.
+                             Options: An integer in seconds, None
+                             Default: 600 seconds
+            wpa_strict_rekey: Whether to do a group key update when client
+                              leaves the network or not.
+                              Options: True, False
+                              Default: True
+            wep_default_key: The wep key number to use when transmitting.
+            radius_server_ip: Radius server IP for Enterprise auth.
+            radius_server_port: Radius server port for Enterprise auth.
+            radius_server_secret: Radius server secret for Enterprise auth.
+        """
+        self.security_mode = security_mode
+        self.wpa_cipher = wpa_cipher
+        self.wpa2_cipher = wpa2_cipher
+        self.wpa_group_rekey = wpa_group_rekey
+        self.wpa_strict_rekey = wpa_strict_rekey
+        self.wep_default_key = wep_default_key
+        self.radius_server_ip = radius_server_ip
+        self.radius_server_port = radius_server_port
+        self.radius_server_secret = radius_server_secret
+        if password:
+            if self.security_mode is SecurityMode.WEP:
+                if len(password) in hostapd_constants.WEP_STR_LENGTH:
+                    self.password = f'"{password}"'
+                elif len(password) in hostapd_constants.WEP_HEX_LENGTH and all(
+                    c in string.hexdigits for c in password
+                ):
+                    self.password = password
+                else:
+                    raise ValueError(
+                        "WEP key must be a hex string of %s characters"
+                        % hostapd_constants.WEP_HEX_LENGTH
+                    )
+            else:
+                if (
+                    len(password) < hostapd_constants.MIN_WPA_PSK_LENGTH
+                    or len(password) > hostapd_constants.MAX_WPA_PSK_LENGTH
+                ):
+                    raise ValueError(
+                        "Password must be a minumum of %s characters and a maximum of %s"
+                        % (
+                            hostapd_constants.MIN_WPA_PSK_LENGTH,
+                            hostapd_constants.MAX_WPA_PSK_LENGTH,
+                        )
+                    )
+                else:
+                    self.password = password
+
+    def __str__(self) -> str:
+        return self.security_mode
+
+    def generate_dict(self) -> dict[str, str | int]:
+        """Returns: an ordered dictionary of settings"""
+        if self.security_mode is SecurityMode.OPEN:
+            return {}
+
+        settings: dict[str, str | int] = collections.OrderedDict()
+
+        if self.security_mode is SecurityMode.WEP:
+            settings["wep_default_key"] = self.wep_default_key
+            settings[f"wep_key{self.wep_default_key}"] = self.password
+        elif self.security_mode == SecurityMode.ENT:
+            if self.radius_server_ip is not None:
+                settings["auth_server_addr"] = self.radius_server_ip
+            if self.radius_server_port is not None:
+                settings["auth_server_port"] = self.radius_server_port
+            if self.radius_server_secret is not None:
+                settings["auth_server_shared_secret"] = self.radius_server_secret
+            settings["wpa_key_mgmt"] = hostapd_constants.ENT_KEY_MGMT
+            settings["ieee8021x"] = hostapd_constants.IEEE8021X
+            settings["wpa"] = hostapd_constants.WPA2
+        else:
+            settings["wpa"] = self.security_mode.security_mode_int().value
+            if len(self.password) == hostapd_constants.MAX_WPA_PSK_LENGTH:
+                settings["wpa_psk"] = self.password
+            else:
+                settings["wpa_passphrase"] = self.password
+            # For wpa, wpa/wpa2, and wpa/wpa2/wpa3, add wpa_pairwise
+            if self.wpa_cipher and (
+                self.security_mode is SecurityMode.WPA
+                or self.security_mode is SecurityMode.WPA_WPA2
+                or self.security_mode is SecurityMode.WPA_WPA2_WPA3
+            ):
+                settings["wpa_pairwise"] = self.wpa_cipher
+            # For wpa/wpa2, wpa2, wpa3, and wpa2/wpa3, and wpa/wpa2, wpa3, add rsn_pairwise
+            if self.wpa2_cipher and (
+                self.security_mode is SecurityMode.WPA_WPA2
+                or self.security_mode is SecurityMode.WPA2
+                or self.security_mode is SecurityMode.WPA2_WPA3
+                or self.security_mode is SecurityMode.WPA3
+            ):
+                settings["rsn_pairwise"] = self.wpa2_cipher
+            # Add wpa_key_mgmt based on security mode string
+            wpa_key_mgmt = self.security_mode.key_management()
+            if wpa_key_mgmt is not None:
+                settings["wpa_key_mgmt"] = str(wpa_key_mgmt)
+            if self.wpa_group_rekey:
+                settings["wpa_group_rekey"] = self.wpa_group_rekey
+            if self.wpa_strict_rekey:
+                settings["wpa_strict_rekey"] = hostapd_constants.WPA_STRICT_REKEY
+
+        return settings
diff --git a/packages/antlion/controllers/ap_lib/hostapd_utils.py b/packages/antlion/controllers/ap_lib/hostapd_utils.py
new file mode 100644
index 0000000..060777e
--- /dev/null
+++ b/packages/antlion/controllers/ap_lib/hostapd_utils.py
@@ -0,0 +1,97 @@
+# 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.
+
+
+from antlion import utils
+from antlion.controllers.ap_lib import hostapd_constants
+from antlion.controllers.ap_lib.hostapd_security import Security, SecurityMode
+
+
+def generate_random_password(
+    security_mode: SecurityMode = SecurityMode.OPEN,
+    length: int | None = None,
+    hex: int | None = None,
+) -> str:
+    """Generates a random password. Defaults to an 8 character ASCII password.
+
+    Args:
+        security_mode: Used to determine if length should be WEP compatible
+            (useful for generated tests to simply pass in security mode)
+        length: Length of password to generate. Defaults to 8, unless
+            security_mode is WEP, then 13
+        hex: If True, generates a hex string, else ascii
+    """
+    if hex:
+        generator_func = utils.rand_hex_str
+    else:
+        generator_func = utils.rand_ascii_str
+
+    if length:
+        return generator_func(length)
+    if security_mode is SecurityMode.WEP:
+        return generator_func(hostapd_constants.WEP_DEFAULT_STR_LENGTH)
+    else:
+        return generator_func(hostapd_constants.MIN_WPA_PSK_LENGTH)
+
+
+def verify_interface(interface: str, valid_interfaces: list[str]) -> None:
+    """Raises error if interface is missing or invalid
+
+    Args:
+        interface: interface name
+        valid_interfaces: valid interface names
+    """
+    if interface not in valid_interfaces:
+        raise ValueError(f"Invalid interface name was passed: {interface}")
+
+
+def verify_security_mode(
+    security_profile: Security, valid_security_modes: list[SecurityMode]
+) -> None:
+    """Raises error if security mode is not in list of valid security modes.
+
+    Args:
+        security_profile: Security to verify
+        valid_security_modes: Valid security modes for a profile.
+    """
+    if security_profile.security_mode not in valid_security_modes:
+        raise ValueError(
+            f"Invalid Security Mode: {security_profile.security_mode}; "
+            f"Valid Security Modes for this profile: {valid_security_modes}"
+        )
+
+
+def verify_cipher(security_profile: Security, valid_ciphers: list[str]) -> None:
+    """Raise error if cipher is not in list of valid ciphers.
+
+    Args:
+        security_profile: Security profile to verify
+        valid_ciphers: A list of valid ciphers for security_profile.
+    """
+    if security_profile.security_mode is SecurityMode.OPEN:
+        raise ValueError("Security mode is open.")
+    elif security_profile.security_mode is SecurityMode.WPA:
+        if security_profile.wpa_cipher not in valid_ciphers:
+            raise ValueError(
+                f"Invalid WPA Cipher: {security_profile.wpa_cipher}. "
+                f"Valid WPA Ciphers for this profile: {valid_ciphers}"
+            )
+    elif security_profile.security_mode is SecurityMode.WPA2:
+        if security_profile.wpa2_cipher not in valid_ciphers:
+            raise ValueError(
+                f"Invalid WPA2 Cipher: {security_profile.wpa2_cipher}. "
+                f"Valid WPA2 Ciphers for this profile: {valid_ciphers}"
+            )
+    else:
+        raise ValueError(f"Invalid Security Mode: {security_profile.security_mode}")
diff --git a/packages/antlion/controllers/ap_lib/radio_measurement.py b/packages/antlion/controllers/ap_lib/radio_measurement.py
new file mode 100644
index 0000000..5c7f2e0
--- /dev/null
+++ b/packages/antlion/controllers/ap_lib/radio_measurement.py
@@ -0,0 +1,246 @@
+#!/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.
+
+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/packages/antlion/controllers/ap_lib/radvd.py b/packages/antlion/controllers/ap_lib/radvd.py
new file mode 100644
index 0000000..cb099d2
--- /dev/null
+++ b/packages/antlion/controllers/ap_lib/radvd.py
@@ -0,0 +1,214 @@
+# 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 logging
+import shlex
+import tempfile
+import time
+
+from antlion.controllers.ap_lib.radvd_config import RadvdConfig
+from antlion.controllers.utils_lib.commands import shell
+from antlion.libs.proc import job
+from antlion.logger import LogLevel
+from antlion.runner import Runner
+
+
+class Error(Exception):
+    """An error caused by radvd."""
+
+
+class Radvd(object):
+    """Manages the radvd program.
+
+    https://en.wikipedia.org/wiki/Radvd
+    This implements the Router Advertisement Daemon of IPv6 router addresses
+    and IPv6 routing prefixes using the Neighbor Discovery Protocol.
+
+    Attributes:
+        config: The radvd configuration that is being used.
+    """
+
+    def __init__(
+        self,
+        runner: Runner,
+        interface: str,
+        working_dir: str | None = None,
+        radvd_binary: str | None = None,
+    ) -> None:
+        """
+        Args:
+            runner: Object that has run_async and run methods for executing
+                    shell commands (e.g. connection.SshConnection)
+            interface: Name of the interface to use (eg. wlan0).
+            working_dir: Directory to work out of.
+            radvd_binary: Location of the radvd binary
+        """
+        if not radvd_binary:
+            logging.debug(
+                "No radvd binary specified.  " "Assuming radvd is in the path."
+            )
+            radvd_binary = "radvd"
+        else:
+            logging.debug(f"Using radvd binary located at {radvd_binary}")
+        if working_dir is None and runner.run == job.run:
+            working_dir = tempfile.gettempdir()
+        else:
+            working_dir = "/tmp"
+        self._radvd_binary = radvd_binary
+        self._runner = runner
+        self._interface = interface
+        self._working_dir = working_dir
+        self.config: RadvdConfig | None = None
+        self._shell = shell.ShellCommand(runner)
+        self._log_file = f"{working_dir}/radvd-{self._interface}.log"
+        self._config_file = f"{working_dir}/radvd-{self._interface}.conf"
+        self._pid_file = f"{working_dir}/radvd-{self._interface}.pid"
+        self._ps_identifier = f"{self._radvd_binary}.*{self._config_file}"
+
+    def start(self, config: RadvdConfig, timeout: int = 60) -> None:
+        """Starts radvd
+
+        Starts the radvd daemon and runs it in the background.
+
+        Args:
+            config: Configs to start the radvd with.
+            timeout: Time to wait for radvd  to come up.
+
+        Returns:
+            True if the daemon could be started. Note that the daemon can still
+            start and not work. Invalid configurations can take a long amount
+            of time to be produced, and because the daemon runs indefinitely
+            it's impossible to wait on. If you need to check if configs are ok
+            then periodic checks to is_running and logs should be used.
+        """
+        if self.is_alive():
+            self.stop()
+
+        self.config = config
+
+        self._shell.delete_file(self._log_file)
+        self._shell.delete_file(self._config_file)
+        self._write_configs(self.config)
+
+        command = (
+            f"{self._radvd_binary} -C {shlex.quote(self._config_file)} "
+            f"-p {shlex.quote(self._pid_file)} -m logfile -d 5 "
+            f'-l {self._log_file} > "{self._log_file}" 2>&1'
+        )
+        self._runner.run_async(command)
+
+        try:
+            self._wait_for_process(timeout=timeout)
+        except Error:
+            self.stop()
+            raise
+
+    def stop(self):
+        """Kills the daemon if it is running."""
+        self._shell.kill(self._ps_identifier)
+
+    def is_alive(self):
+        """
+        Returns:
+            True if the daemon is running.
+        """
+        return self._shell.is_alive(self._ps_identifier)
+
+    def pull_logs(self) -> str:
+        """Pulls the log files from where radvd is running.
+
+        Returns:
+            A string of the radvd logs.
+        """
+        # TODO: Auto pulling of logs when stop is called.
+        with LogLevel(self._runner.log, logging.INFO):
+            return self._shell.read_file(self._log_file)
+
+    def _wait_for_process(self, timeout: int = 60) -> None:
+        """Waits for the process to come up.
+
+        Waits until the radvd process is found running, or there is
+        a timeout. If the program never comes up then the log file
+        will be scanned for errors.
+
+        Raises: See _scan_for_errors
+        """
+        start_time = time.time()
+        while time.time() - start_time < timeout and not self.is_alive():
+            time.sleep(0.1)
+            self._scan_for_errors(False)
+        self._scan_for_errors(True)
+
+    def _scan_for_errors(self, should_be_up: bool) -> None:
+        """Scans the radvd log for any errors.
+
+        Args:
+            should_be_up: If true then radvd program is expected to be alive.
+                          If it is found not alive while this is true an error
+                          is thrown.
+
+        Raises:
+            Error: Raised when a radvd error is found.
+        """
+        # Store this so that all other errors have priority.
+        is_dead = not self.is_alive()
+
+        exited_prematurely = self._shell.search_file("Exiting", self._log_file)
+        if exited_prematurely:
+            raise Error("Radvd exited prematurely.", self)
+        if should_be_up and is_dead:
+            raise Error("Radvd failed to start", self)
+
+    def _write_configs(self, config: RadvdConfig) -> None:
+        """Writes the configs to the radvd config file.
+
+        Args:
+            config: a RadvdConfig object.
+        """
+        self._shell.delete_file(self._config_file)
+        conf = config.package_configs()
+        lines = ["interface %s {" % self._interface]
+        for interface_option_key, interface_option in conf["interface_options"].items():
+            lines.append(f"\t{str(interface_option_key)} {str(interface_option)};")
+        lines.append(f"\tprefix {conf['prefix']}")
+        lines.append("\t{")
+        for prefix_option in conf["prefix_options"].items():
+            lines.append(f"\t\t{' '.join(map(str, prefix_option))};")
+        lines.append("\t};")
+        if conf["clients"]:
+            lines.append("\tclients")
+            lines.append("\t{")
+            for client in conf["clients"]:
+                lines.append(f"\t\t{client};")
+            lines.append("\t};")
+        if conf["route"]:
+            lines.append("\troute %s {" % conf["route"])
+            for route_option in conf["route_options"].items():
+                lines.append(f"\t\t{' '.join(map(str, route_option))};")
+            lines.append("\t};")
+        if conf["rdnss"]:
+            lines.append(
+                "\tRDNSS %s {" % " ".join([str(elem) for elem in conf["rdnss"]])
+            )
+            for rdnss_option in conf["rdnss_options"].items():
+                lines.append(f"\t\t{' '.join(map(str, rdnss_option))};")
+            lines.append("\t};")
+        lines.append("};")
+        output_config = "\n".join(lines)
+        logging.info(f"Writing {self._config_file}")
+        logging.debug("******************Start*******************")
+        logging.debug(f"\n{output_config}")
+        logging.debug("*******************End********************")
+
+        self._shell.write_file(self._config_file, output_config)
diff --git a/packages/antlion/controllers/ap_lib/radvd_config.py b/packages/antlion/controllers/ap_lib/radvd_config.py
new file mode 100644
index 0000000..d3d6d97
--- /dev/null
+++ b/packages/antlion/controllers/ap_lib/radvd_config.py
@@ -0,0 +1,313 @@
+# 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 collections
+from typing import Any
+
+from antlion.controllers.ap_lib import radvd_constants
+
+
+class RadvdConfig(object):
+    """The root settings for the router advertisement daemon.
+
+    All the settings for a router advertisement daemon.
+    """
+
+    def __init__(
+        self,
+        prefix: str = radvd_constants.DEFAULT_PREFIX,
+        clients: list[str] = [],
+        route: Any | None = None,
+        rdnss: list[str] = [],
+        ignore_if_missing: str | None = None,
+        adv_send_advert: str = radvd_constants.ADV_SEND_ADVERT_ON,
+        unicast_only: str | None = None,
+        max_rtr_adv_interval: int | None = None,
+        min_rtr_adv_interval: int | None = None,
+        min_delay_between_ras: int | None = None,
+        adv_managed_flag: str | None = None,
+        adv_other_config_flag: str | None = None,
+        adv_link_mtu: int | None = None,
+        adv_reachable_time: int | None = None,
+        adv_retrans_timer: int | None = None,
+        adv_cur_hop_limit: int | None = None,
+        adv_default_lifetime: int | None = None,
+        adv_default_preference: str | None = None,
+        adv_source_ll_address: str | None = None,
+        adv_home_agent_flag: str | None = None,
+        adv_home_agent_info: str | None = None,
+        home_agent_lifetime: int | None = None,
+        home_agent_preference: int | None = None,
+        adv_mob_rtr_support_flag: str | None = None,
+        adv_interval_opt: str | None = None,
+        adv_on_link: str = radvd_constants.ADV_ON_LINK_ON,
+        adv_autonomous: str = radvd_constants.ADV_AUTONOMOUS_ON,
+        adv_router_addr: str | None = None,
+        adv_valid_lifetime: int | None = None,
+        adv_preferred_lifetime: int | None = None,
+        base_6to4_interface: str | None = None,
+        adv_route_lifetime: int | None = None,
+        adv_route_preference: str | None = None,
+        adv_rdnss_preference: int | None = None,
+        adv_rdnss_open: str | None = None,
+        adv_rdnss_lifetime: int | None = None,
+    ) -> None:
+        """Construct a RadvdConfig.
+
+        Args:
+            prefix: IPv6 prefix and length, ie fd::/64
+            clients: A list of IPv6 link local addresses that will be the only
+                clients served.  All other IPv6 addresses will be ignored if
+                this list is present.
+            route: A route for the router advertisement with prefix.
+            rdnss: A list of recursive DNS servers
+            ignore_if_missing: A flag indicating whether or not the interface
+                is ignored if it does not exist at start-up. By default,
+                radvd exits.
+            adv_send_advert: A flag indicating whether or not the router sends
+                periodic router advertisements and responds to router
+                solicitations.
+            unicast_only: Indicates that the interface link type only supports
+                unicast.
+            max_rtr_adv_interval:The maximum time allowed between sending
+                unsolicited multicast router advertisements from the interface,
+                in seconds. Must be no less than 4 seconds and no greater than
+                1800 seconds.
+            min_rtr_adv_interval: The minimum time allowed between sending
+                unsolicited multicast router advertisements from the interface,
+                in seconds. Must be no less than 3 seconds and no greater than
+                0.75 * max_rtr_adv_interval.
+            min_delay_between_ras: The minimum time allowed between sending
+                multicast router advertisements from the interface, in seconds.,
+            adv_managed_flag: When set, hosts use the administered (stateful)
+                protocol for address autoconfiguration in addition to any
+                addresses autoconfigured using stateless address
+                autoconfiguration. The use of this flag is described in
+                RFC 4862.
+            adv_other_config_flag: When set, hosts use the administered
+                (stateful) protocol for autoconfiguration of other (non-address)
+                information. The use of this flag is described in RFC 4862.
+            adv_link_mtu: The MTU option is used in router advertisement
+                messages to insure that all nodes on a link use the same MTU
+                value in those cases where the link MTU is not well known.
+            adv_reachable_time: The time, in milliseconds, that a node assumes
+                a neighbor is reachable after having received a reachability
+                confirmation. Used by the Neighbor Unreachability Detection
+                algorithm (see Section 7.3 of RFC 4861). A value of zero means
+                unspecified (by this router).
+            adv_retrans_timer: The time, in milliseconds, between retransmitted
+                Neighbor Solicitation messages. Used by address resolution and
+                the Neighbor Unreachability Detection algorithm (see Sections
+                7.2 and 7.3 of RFC 4861). A value of zero means unspecified
+                (by this router).
+            adv_cur_hop_limit: The default value that should be placed in the
+                Hop Count field of the IP header for outgoing (unicast) IP
+                packets. The value should be set to the current diameter of the
+                Internet. The value zero means unspecified (by this router).
+            adv_default_lifetime: The lifetime associated with the default
+                router in units of seconds. The maximum value corresponds to
+                18.2 hours. A lifetime of 0 indicates that the router is not a
+                default router and should not appear on the default router list.
+                The router lifetime applies only to the router's usefulness as
+                a default router; it does not apply to information contained in
+                other message fields or options. Options that need time limits
+                for their information include their own lifetime fields.
+            adv_default_preference: The preference associated with the default
+                router, as either "low", "medium", or "high".
+            adv_source_ll_address: When set, the link-layer address of the
+                outgoing interface is included in the RA.
+            adv_home_agent_flag: When set, indicates that sending router is able
+                to serve as Mobile IPv6 Home Agent. When set, minimum limits
+                specified by Mobile IPv6 are used for MinRtrAdvInterval and
+                MaxRtrAdvInterval.
+            adv_home_agent_info: When set, Home Agent Information Option
+                (specified by Mobile IPv6) is included in Router Advertisements.
+                adv_home_agent_flag must also be set when using this option.
+            home_agent_lifetime: The length of time in seconds (relative to the
+                time the packet is sent) that the router is offering Mobile IPv6
+                 Home Agent services. A value 0 must not be used. The maximum
+                 lifetime is 65520 seconds (18.2 hours). This option is ignored,
+                 if adv_home_agent_info is not set.
+            home_agent_preference: The preference for the Home Agent sending
+                this Router Advertisement. Values greater than 0 indicate more
+                preferable Home Agent, values less than 0 indicate less
+                preferable Home Agent. This option is ignored, if
+                adv_home_agent_info is not set.
+            adv_mob_rtr_support_flag: When set, the Home Agent signals it
+                supports Mobile Router registrations (specified by NEMO Basic).
+                adv_home_agent_info must also be set when using this option.
+            adv_interval_opt: When set, Advertisement Interval Option
+                (specified by Mobile IPv6) is included in Router Advertisements.
+                When set, minimum limits specified by Mobile IPv6 are used for
+                MinRtrAdvInterval and MaxRtrAdvInterval.
+            adv_on_linkWhen set, indicates that this prefix can be used for
+                on-link determination. When not set the advertisement makes no
+                statement about on-link or off-link properties of the prefix.
+                For instance, the prefix might be used for address configuration
+                 with some of the addresses belonging to the prefix being
+                 on-link and others being off-link.
+            adv_autonomous: When set, indicates that this prefix can be used for
+                autonomous address configuration as specified in RFC 4862.
+            adv_router_addr: When set, indicates that the address of interface
+                is sent instead of network prefix, as is required by Mobile
+                IPv6. When set, minimum limits specified by Mobile IPv6 are used
+                for MinRtrAdvInterval and MaxRtrAdvInterval.
+            adv_valid_lifetime: The length of time in seconds (relative to the
+                time the packet is sent) that the prefix is valid for the
+                purpose of on-link determination. The symbolic value infinity
+                represents infinity (i.e. a value of all one bits (0xffffffff)).
+                 The valid lifetime is also used by RFC 4862.
+            adv_preferred_lifetimeThe length of time in seconds (relative to the
+                time the packet is sent) that addresses generated from the
+                prefix via stateless address autoconfiguration remain preferred.
+                The symbolic value infinity represents infinity (i.e. a value of
+                all one bits (0xffffffff)). See RFC 4862.
+            base_6to4_interface: If this option is specified, this prefix will
+                be combined with the IPv4 address of interface name to produce
+                a valid 6to4 prefix. The first 16 bits of this prefix will be
+                replaced by 2002 and the next 32 bits of this prefix will be
+                replaced by the IPv4 address assigned to interface name at
+                configuration time. The remaining 80 bits of the prefix
+                (including the SLA ID) will be advertised as specified in the
+                configuration file.
+            adv_route_lifetime: The lifetime associated with the route in units
+                of seconds. The symbolic value infinity represents infinity
+                (i.e. a value of all one bits (0xffffffff)).
+            adv_route_preference: The preference associated with the default
+                router, as either "low", "medium", or "high".
+            adv_rdnss_preference: The preference of the DNS server, compared to
+                other DNS servers advertised and used. 0 to 7 means less
+                important than manually configured nameservers in resolv.conf,
+                while 12 to 15 means more important.
+            adv_rdnss_open: "Service Open" flag. When set, indicates that RDNSS
+                continues to be available to hosts even if they moved to a
+                different subnet.
+            adv_rdnss_lifetime: The maximum duration how long the RDNSS entries
+                are used for name resolution. A value of 0 means the nameserver
+                should no longer be used. The maximum duration how long the
+                RDNSS entries are used for name resolution. A value of 0 means
+                the nameserver should no longer be used. The value, if not 0,
+                must be at least max_rtr_adv_interval. To ensure stale RDNSS
+                info gets removed in a timely fashion, this should not be
+                greater than 2*max_rtr_adv_interval.
+        """
+        self._prefix = prefix
+        self._clients = clients
+        self._route = route
+        self._rdnss = rdnss
+        self._ignore_if_missing = ignore_if_missing
+        self._adv_send_advert = adv_send_advert
+        self._unicast_only = unicast_only
+        self._max_rtr_adv_interval = max_rtr_adv_interval
+        self._min_rtr_adv_interval = min_rtr_adv_interval
+        self._min_delay_between_ras = min_delay_between_ras
+        self._adv_managed_flag = adv_managed_flag
+        self._adv_other_config_flag = adv_other_config_flag
+        self._adv_link_mtu = adv_link_mtu
+        self._adv_reachable_time = adv_reachable_time
+        self._adv_retrans_timer = adv_retrans_timer
+        self._adv_cur_hop_limit = adv_cur_hop_limit
+        self._adv_default_lifetime = adv_default_lifetime
+        self._adv_default_preference = adv_default_preference
+        self._adv_source_ll_address = adv_source_ll_address
+        self._adv_home_agent_flag = adv_home_agent_flag
+        self._adv_home_agent_info = adv_home_agent_info
+        self._home_agent_lifetime = home_agent_lifetime
+        self._home_agent_preference = home_agent_preference
+        self._adv_mob_rtr_support_flag = adv_mob_rtr_support_flag
+        self._adv_interval_opt = adv_interval_opt
+        self._adv_on_link = adv_on_link
+        self._adv_autonomous = adv_autonomous
+        self._adv_router_addr = adv_router_addr
+        self._adv_valid_lifetime = adv_valid_lifetime
+        self._adv_preferred_lifetime = adv_preferred_lifetime
+        self._base_6to4_interface = base_6to4_interface
+        self._adv_route_lifetime = adv_route_lifetime
+        self._adv_route_preference = adv_route_preference
+        self._adv_rdnss_preference = adv_rdnss_preference
+        self._adv_rdnss_open = adv_rdnss_open
+        self._adv_rdnss_lifetime = adv_rdnss_lifetime
+
+    def package_configs(self):
+        conf: dict[str, Any] = dict()
+        conf["prefix"] = self._prefix
+        conf["clients"] = self._clients
+        conf["route"] = self._route
+        conf["rdnss"] = self._rdnss
+
+        conf["interface_options"] = collections.OrderedDict(
+            filter(
+                lambda pair: pair[1] is not None,
+                (
+                    ("IgnoreIfMissing", self._ignore_if_missing),
+                    ("AdvSendAdvert", self._adv_send_advert),
+                    ("UnicastOnly", self._unicast_only),
+                    ("MaxRtrAdvInterval", self._max_rtr_adv_interval),
+                    ("MinRtrAdvInterval", self._min_rtr_adv_interval),
+                    ("MinDelayBetweenRAs", self._min_delay_between_ras),
+                    ("AdvManagedFlag", self._adv_managed_flag),
+                    ("AdvOtherConfigFlag", self._adv_other_config_flag),
+                    ("AdvLinkMTU", self._adv_link_mtu),
+                    ("AdvReachableTime", self._adv_reachable_time),
+                    ("AdvRetransTimer", self._adv_retrans_timer),
+                    ("AdvCurHopLimit", self._adv_cur_hop_limit),
+                    ("AdvDefaultLifetime", self._adv_default_lifetime),
+                    ("AdvDefaultPreference", self._adv_default_preference),
+                    ("AdvSourceLLAddress", self._adv_source_ll_address),
+                    ("AdvHomeAgentFlag", self._adv_home_agent_flag),
+                    ("AdvHomeAgentInfo", self._adv_home_agent_info),
+                    ("HomeAgentLifetime", self._home_agent_lifetime),
+                    ("HomeAgentPreference", self._home_agent_preference),
+                    ("AdvMobRtrSupportFlag", self._adv_mob_rtr_support_flag),
+                    ("AdvIntervalOpt", self._adv_interval_opt),
+                ),
+            )
+        )
+
+        conf["prefix_options"] = collections.OrderedDict(
+            filter(
+                lambda pair: pair[1] is not None,
+                (
+                    ("AdvOnLink", self._adv_on_link),
+                    ("AdvAutonomous", self._adv_autonomous),
+                    ("AdvRouterAddr", self._adv_router_addr),
+                    ("AdvValidLifetime", self._adv_valid_lifetime),
+                    ("AdvPreferredLifetime", self._adv_preferred_lifetime),
+                    ("Base6to4Interface", self._base_6to4_interface),
+                ),
+            )
+        )
+
+        conf["route_options"] = collections.OrderedDict(
+            filter(
+                lambda pair: pair[1] is not None,
+                (
+                    ("AdvRouteLifetime", self._adv_route_lifetime),
+                    ("AdvRoutePreference", self._adv_route_preference),
+                ),
+            )
+        )
+
+        conf["rdnss_options"] = collections.OrderedDict(
+            filter(
+                lambda pair: pair[1] is not None,
+                (
+                    ("AdvRDNSSPreference", self._adv_rdnss_preference),
+                    ("AdvRDNSSOpen", self._adv_rdnss_open),
+                    ("AdvRDNSSLifetime", self._adv_rdnss_lifetime),
+                ),
+            )
+        )
+
+        return conf
diff --git a/packages/antlion/controllers/ap_lib/radvd_constants.py b/packages/antlion/controllers/ap_lib/radvd_constants.py
new file mode 100644
index 0000000..b02a694
--- /dev/null
+++ b/packages/antlion/controllers/ap_lib/radvd_constants.py
@@ -0,0 +1,66 @@
+#!/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.
+
+DEFAULT_PREFIX = "fd00::/64"
+
+IGNORE_IF_MISSING_ON = "on"
+IGNORE_IF_MISSING_OFF = "off"
+
+ADV_SEND_ADVERT_ON = "on"
+ADV_SEND_ADVERT_OFF = "off"
+
+UNICAST_ONLY_ON = "on"
+UNICAST_ONLY_OFF = "off"
+
+ADV_MANAGED_FLAG_ON = "on"
+ADV_MANAGED_FLAG_OFF = "off"
+
+ADV_OTHER_CONFIG_FLAG_ON = "on"
+ADV_OTHER_CONFIG_FLAG_OFF = "off"
+
+ADV_DEFAULT_PREFERENCE_ON = "on"
+ADV_DEFAULT_PREFERENCE_OFF = "off"
+
+ADV_SOURCE_LL_ADDRESS_ON = "on"
+ADV_SOURCE_LL_ADDRESS_OFF = "off"
+
+ADV_HOME_AGENT_FLAG_ON = "on"
+ADV_HOME_AGENT_FLAG_OFF = "off"
+
+ADV_HOME_AGENT_INFO_ON = "on"
+ADV_HOME_AGENT_INFO_OFF = "off"
+
+ADV_MOB_RTR_SUPPORT_FLAG_ON = "on"
+ADV_MOB_RTR_SUPPORT_FLAG_OFF = "off"
+
+ADV_INTERVAL_OPT_ON = "on"
+ADV_INTERVAL_OPT_OFF = "off"
+
+ADV_ON_LINK_ON = "on"
+ADV_ON_LINK_OFF = "off"
+
+ADV_AUTONOMOUS_ON = "on"
+ADV_AUTONOMOUS_OFF = "off"
+
+ADV_ROUTER_ADDR_ON = "on"
+ADV_ROUTER_ADDR_OFF = "off"
+
+ADV_ROUTE_PREFERENCE_LOW = "low"
+ADV_ROUTE_PREFERENCE_MED = "medium"
+ADV_ROUTE_PREFERENCE_HIGH = "high"
+
+ADV_RDNSS_OPEN_ON = "on"
+ADV_RDNSS_OPEN_OFF = "off"
diff --git a/packages/antlion/controllers/ap_lib/regulatory_channels.py b/packages/antlion/controllers/ap_lib/regulatory_channels.py
new file mode 100644
index 0000000..432607c
--- /dev/null
+++ b/packages/antlion/controllers/ap_lib/regulatory_channels.py
@@ -0,0 +1,710 @@
+from dataclasses import dataclass
+
+Channel = int
+Bandwidth = int
+# TODO(http://b/281728764): Add device requirements to each frequency e.g.
+# "MUST be used indoors only" or "MUST be used with DFS".
+ChannelBandwidthMap = dict[Channel, list[Bandwidth]]
+
+
+@dataclass
+class CountryChannels:
+    country_code: str
+    allowed_channels: ChannelBandwidthMap
+
+
+# All antlion-supported channels and frequencies for use in regulatory testing.
+TEST_CHANNELS: ChannelBandwidthMap = {
+    1: [20],
+    2: [20],
+    3: [20],
+    4: [20],
+    5: [20],
+    6: [20],
+    7: [20],
+    8: [20],
+    9: [20],
+    10: [20],
+    11: [20],
+    12: [20],
+    13: [20],
+    14: [20],
+    36: [20, 40, 80],
+    40: [20, 40, 80],
+    44: [20, 40, 80],
+    48: [20, 40, 80],
+    52: [20, 40, 80],
+    56: [20, 40, 80],
+    60: [20, 40, 80],
+    64: [20, 40, 80],
+    100: [20, 40, 80],
+    104: [20, 40, 80],
+    108: [20, 40, 80],
+    112: [20, 40, 80],
+    116: [20, 40, 80],
+    120: [20, 40, 80],
+    124: [20, 40, 80],
+    128: [20, 40, 80],
+    132: [20, 40, 80],
+    136: [20, 40, 80],
+    140: [20, 40, 80],
+    144: [20, 40, 80],
+    149: [20, 40, 80],
+    153: [20, 40, 80],
+    157: [20, 40, 80],
+    161: [20, 40, 80],
+    165: [20],
+}
+
+# All universally accepted 2.4GHz channels and frequencies.
+WORLD_WIDE_2G_CHANNELS: ChannelBandwidthMap = {
+    1: [20],
+    2: [20],
+    3: [20],
+    4: [20],
+    5: [20],
+    6: [20],
+    7: [20],
+    8: [20],
+    9: [20],
+    10: [20],
+    11: [20],
+}
+
+# List of supported channels and frequencies by country.
+#
+# Please keep this alphabetically ordered. Thanks!
+#
+# TODO: Add missing countries: Russia, Israel, Korea, Turkey, South Africa,
+# Brazil, Bahrain, Vietnam
+COUNTRY_CHANNELS = {
+    "Australia": CountryChannels(
+        country_code="AU",
+        allowed_channels=WORLD_WIDE_2G_CHANNELS
+        | {
+            12: [20],
+            13: [20],
+            36: [20, 40, 80],
+            40: [20, 40, 80],
+            44: [20, 40, 80],
+            48: [20, 40, 80],
+            52: [20, 40, 80],
+            56: [20, 40, 80],
+            60: [20, 40, 80],
+            64: [20, 40, 80],
+            100: [20, 40, 80],
+            104: [20, 40, 80],
+            108: [20, 40, 80],
+            112: [20, 40, 80],
+            116: [20, 40, 80],
+            132: [20, 40, 80],
+            136: [20, 40, 80],
+            140: [20, 40, 80],
+            144: [20, 40, 80],
+            149: [20, 40, 80],
+            153: [20, 40, 80],
+            157: [20, 40, 80],
+            161: [20, 40, 80],
+            165: [20],
+        },
+    ),
+    "Austria": CountryChannels(
+        country_code="AT",
+        allowed_channels=WORLD_WIDE_2G_CHANNELS
+        | {
+            12: [20],
+            13: [20],
+            36: [20, 40, 80],
+            40: [20, 40, 80],
+            44: [20, 40, 80],
+            48: [20, 40, 80],
+            52: [20, 40, 80],
+            56: [20, 40, 80],
+            60: [20, 40, 80],
+            64: [20, 40, 80],
+            100: [20, 40, 80],
+            104: [20, 40, 80],
+            108: [20, 40, 80],
+            112: [20, 40, 80],
+            116: [20, 40, 80],
+            120: [20, 40, 80],
+            124: [20, 40, 80],
+            128: [20, 40, 80],
+            132: [20, 40, 80],
+            136: [20, 40, 80],
+            140: [20, 40, 80],
+        },
+    ),
+    "Belgium": CountryChannels(
+        country_code="BE",
+        allowed_channels=WORLD_WIDE_2G_CHANNELS
+        | {
+            12: [20],
+            13: [20],
+            36: [20, 40, 80],
+            40: [20, 40, 80],
+            44: [20, 40, 80],
+            48: [20, 40, 80],
+            52: [20, 40, 80],
+            56: [20, 40, 80],
+            60: [20, 40, 80],
+            64: [20, 40, 80],
+            100: [20, 40, 80],
+            104: [20, 40, 80],
+            108: [20, 40, 80],
+            112: [20, 40, 80],
+            116: [20, 40, 80],
+            120: [20, 40, 80],
+            124: [20, 40, 80],
+            128: [20, 40, 80],
+            132: [20, 40, 80],
+            136: [20, 40, 80],
+            140: [20, 40, 80],
+        },
+    ),
+    "Canada": CountryChannels(
+        country_code="CA",
+        allowed_channels=WORLD_WIDE_2G_CHANNELS
+        | {
+            36: [20, 40, 80],
+            40: [20, 40, 80],
+            44: [20, 40, 80],
+            48: [20, 40, 80],
+            52: [20, 40, 80],
+            56: [20, 40, 80],
+            60: [20, 40, 80],
+            64: [20, 40, 80],
+            100: [20, 40, 80],
+            104: [20, 40, 80],
+            108: [20, 40, 80],
+            112: [20, 40, 80],
+            116: [20, 40, 80],
+            132: [20, 40, 80],
+            136: [20, 40, 80],
+            140: [20, 40, 80],
+            144: [20, 40, 80],
+            149: [20, 40, 80],
+            153: [20, 40, 80],
+            157: [20, 40, 80],
+            161: [20, 40, 80],
+            165: [20],
+        },
+    ),
+    "China": CountryChannels(
+        country_code="CH",
+        allowed_channels=WORLD_WIDE_2G_CHANNELS
+        | {
+            12: [20],
+            13: [20],
+            36: [20, 40, 80],
+            40: [20, 40, 80],
+            44: [20, 40, 80],
+            48: [20, 40, 80],
+            52: [20, 40, 80],
+            56: [20, 40, 80],
+            60: [20, 40, 80],
+            64: [20, 40, 80],
+            100: [20, 40, 80],
+            104: [20, 40, 80],
+            108: [20, 40, 80],
+            112: [20, 40, 80],
+            116: [20, 40, 80],
+            120: [20, 40, 80],
+            124: [20, 40, 80],
+            128: [20, 40, 80],
+            132: [20, 40, 80],
+            136: [20, 40, 80],
+            140: [20, 40, 80],
+        },
+    ),
+    "Denmark": CountryChannels(
+        country_code="DK",
+        allowed_channels=WORLD_WIDE_2G_CHANNELS
+        | {
+            12: [20],
+            13: [20],
+            36: [20, 40, 80],
+            40: [20, 40, 80],
+            44: [20, 40, 80],
+            48: [20, 40, 80],
+            52: [20, 40, 80],
+            56: [20, 40, 80],
+            60: [20, 40, 80],
+            64: [20, 40, 80],
+            100: [20, 40, 80],
+            104: [20, 40, 80],
+            108: [20, 40, 80],
+            112: [20, 40, 80],
+            116: [20, 40, 80],
+            120: [20, 40, 80],
+            124: [20, 40, 80],
+            128: [20, 40, 80],
+            132: [20, 40, 80],
+            136: [20, 40, 80],
+            140: [20, 40, 80],
+        },
+    ),
+    "France": CountryChannels(
+        country_code="FR",
+        allowed_channels=WORLD_WIDE_2G_CHANNELS
+        | {
+            12: [20],
+            13: [20],
+            36: [20, 40, 80],
+            40: [20, 40, 80],
+            44: [20, 40, 80],
+            48: [20, 40, 80],
+            52: [20, 40, 80],
+            56: [20, 40, 80],
+            60: [20, 40, 80],
+            64: [20, 40, 80],
+            100: [20, 40, 80],
+            104: [20, 40, 80],
+            108: [20, 40, 80],
+            112: [20, 40, 80],
+            116: [20, 40, 80],
+            120: [20, 40, 80],
+            124: [20, 40, 80],
+            128: [20, 40, 80],
+            132: [20, 40, 80],
+            136: [20, 40, 80],
+            140: [20, 40, 80],
+        },
+    ),
+    "Germany": CountryChannels(
+        country_code="DE",
+        allowed_channels=WORLD_WIDE_2G_CHANNELS
+        | {
+            12: [20],
+            13: [20],
+            36: [20, 40, 80],
+            40: [20, 40, 80],
+            44: [20, 40, 80],
+            48: [20, 40, 80],
+            52: [20, 40, 80],
+            56: [20, 40, 80],
+            60: [20, 40, 80],
+            64: [20, 40, 80],
+            100: [20, 40, 80],
+            104: [20, 40, 80],
+            108: [20, 40, 80],
+            112: [20, 40, 80],
+            116: [20, 40, 80],
+            120: [20, 40, 80],
+            124: [20, 40, 80],
+            128: [20, 40, 80],
+            132: [20, 40, 80],
+            136: [20, 40, 80],
+            140: [20, 40, 80],
+        },
+    ),
+    "India": CountryChannels(
+        country_code="IN",
+        allowed_channels=WORLD_WIDE_2G_CHANNELS
+        | {
+            12: [20],
+            13: [20],
+            36: [20, 40, 80],
+            40: [20, 40, 80],
+            44: [20, 40, 80],
+            48: [20, 40, 80],
+            52: [20, 40, 80],
+            56: [20, 40, 80],
+            60: [20, 40, 80],
+            64: [20, 40, 80],
+            100: [20, 40, 80],
+            104: [20, 40, 80],
+            108: [20, 40, 80],
+            112: [20, 40, 80],
+            116: [20, 40, 80],
+            120: [20, 40, 80],
+            124: [20, 40, 80],
+            128: [20, 40, 80],
+            132: [20, 40, 80],
+            136: [20, 40, 80],
+            140: [20, 40, 80],
+            144: [20, 40, 80],
+            149: [20, 40, 80],
+            153: [20, 40, 80],
+            157: [20, 40, 80],
+            161: [20, 40, 80],
+            165: [20],
+        },
+    ),
+    "Ireland": CountryChannels(
+        country_code="IE",
+        allowed_channels=WORLD_WIDE_2G_CHANNELS
+        | {
+            12: [20],
+            13: [20],
+            36: [20, 40, 80],
+            40: [20, 40, 80],
+            44: [20, 40, 80],
+            48: [20, 40, 80],
+            52: [20, 40, 80],
+            56: [20, 40, 80],
+            60: [20, 40, 80],
+            64: [20, 40, 80],
+            100: [20, 40, 80],
+            104: [20, 40, 80],
+            108: [20, 40, 80],
+            112: [20, 40, 80],
+            116: [20, 40, 80],
+            120: [20, 40, 80],
+            124: [20, 40, 80],
+            128: [20, 40, 80],
+            132: [20, 40, 80],
+            136: [20, 40, 80],
+            140: [20, 40, 80],
+        },
+    ),
+    "Italy": CountryChannels(
+        country_code="IT",
+        allowed_channels=WORLD_WIDE_2G_CHANNELS
+        | {
+            12: [20],
+            13: [20],
+            36: [20, 40, 80],
+            40: [20, 40, 80],
+            44: [20, 40, 80],
+            48: [20, 40, 80],
+            52: [20, 40, 80],
+            56: [20, 40, 80],
+            60: [20, 40, 80],
+            64: [20, 40, 80],
+            100: [20, 40, 80],
+            104: [20, 40, 80],
+            108: [20, 40, 80],
+            112: [20, 40, 80],
+            116: [20, 40, 80],
+            120: [20, 40, 80],
+            124: [20, 40, 80],
+            128: [20, 40, 80],
+            132: [20, 40, 80],
+            136: [20, 40, 80],
+            140: [20, 40, 80],
+        },
+    ),
+    "Japan": CountryChannels(
+        country_code="JP",
+        allowed_channels=WORLD_WIDE_2G_CHANNELS
+        | {
+            12: [20],
+            13: [20],
+            36: [20, 40, 80],
+            40: [20, 40, 80],
+            44: [20, 40, 80],
+            48: [20, 40, 80],
+            52: [20, 40, 80],
+            56: [20, 40, 80],
+            60: [20, 40, 80],
+            64: [20, 40, 80],
+            100: [20, 40, 80],
+            104: [20, 40, 80],
+            108: [20, 40, 80],
+            112: [20, 40, 80],
+            116: [20, 40, 80],
+            120: [20, 40, 80],
+            124: [20, 40, 80],
+            128: [20, 40, 80],
+            132: [20, 40, 80],
+            136: [20, 40, 80],
+            140: [20, 40, 80],
+            144: [20, 40, 80],
+        },
+    ),
+    "Mexico": CountryChannels(
+        country_code="MX",
+        allowed_channels=WORLD_WIDE_2G_CHANNELS
+        | {
+            36: [20, 40, 80],
+            40: [20, 40, 80],
+            44: [20, 40, 80],
+            48: [20, 40, 80],
+            52: [20, 40, 80],
+            56: [20, 40, 80],
+            60: [20, 40, 80],
+            64: [20, 40, 80],
+            100: [20, 40, 80],
+            104: [20, 40, 80],
+            108: [20, 40, 80],
+            112: [20, 40, 80],
+            116: [20, 40, 80],
+            132: [20, 40, 80],
+            136: [20, 40, 80],
+            140: [20, 40, 80],
+            144: [20, 40, 80],
+            149: [20, 40, 80],
+            153: [20, 40, 80],
+            157: [20, 40, 80],
+            161: [20, 40, 80],
+            165: [20],
+        },
+    ),
+    "Netherlands": CountryChannels(
+        country_code="NL",
+        allowed_channels=WORLD_WIDE_2G_CHANNELS
+        | {
+            12: [20],
+            13: [20],
+            36: [20, 40, 80],
+            40: [20, 40, 80],
+            44: [20, 40, 80],
+            48: [20, 40, 80],
+            52: [20, 40, 80],
+            56: [20, 40, 80],
+            60: [20, 40, 80],
+            64: [20, 40, 80],
+            100: [20, 40, 80],
+            104: [20, 40, 80],
+            108: [20, 40, 80],
+            112: [20, 40, 80],
+            116: [20, 40, 80],
+            120: [20, 40, 80],
+            124: [20, 40, 80],
+            128: [20, 40, 80],
+            132: [20, 40, 80],
+            136: [20, 40, 80],
+            140: [20, 40, 80],
+        },
+    ),
+    "New Zealand": CountryChannels(
+        country_code="NZ",
+        allowed_channels=WORLD_WIDE_2G_CHANNELS
+        | {
+            12: [20],
+            13: [20],
+            36: [20, 40, 80],
+            40: [20, 40, 80],
+            44: [20, 40, 80],
+            48: [20, 40, 80],
+            52: [20, 40, 80],
+            56: [20, 40, 80],
+            60: [20, 40, 80],
+            64: [20, 40, 80],
+            100: [20, 40, 80],
+            104: [20, 40, 80],
+            108: [20, 40, 80],
+            112: [20, 40, 80],
+            116: [20, 40, 80],
+            120: [20, 40, 80],
+            124: [20, 40, 80],
+            128: [20, 40, 80],
+            132: [20, 40, 80],
+            136: [20, 40, 80],
+            140: [20, 40, 80],
+            144: [20, 40, 80],
+            149: [20, 40, 80],
+            153: [20, 40, 80],
+            157: [20, 40, 80],
+            161: [20, 40, 80],
+            165: [20],
+        },
+    ),
+    "Norway": CountryChannels(
+        country_code="NO",
+        allowed_channels=WORLD_WIDE_2G_CHANNELS
+        | {
+            12: [20],
+            13: [20],
+            36: [20, 40, 80],
+            40: [20, 40, 80],
+            44: [20, 40, 80],
+            48: [20, 40, 80],
+            52: [20, 40, 80],
+            56: [20, 40, 80],
+            60: [20, 40, 80],
+            64: [20, 40, 80],
+            100: [20, 40, 80],
+            104: [20, 40, 80],
+            108: [20, 40, 80],
+            112: [20, 40, 80],
+            116: [20, 40, 80],
+            120: [20, 40, 80],
+            124: [20, 40, 80],
+            128: [20, 40, 80],
+            132: [20, 40, 80],
+            136: [20, 40, 80],
+            140: [20, 40, 80],
+        },
+    ),
+    "Singapore": CountryChannels(
+        country_code="SG",
+        allowed_channels=WORLD_WIDE_2G_CHANNELS
+        | {
+            12: [20],
+            13: [20],
+            36: [20, 40, 80],
+            40: [20, 40, 80],
+            44: [20, 40, 80],
+            48: [20, 40, 80],
+            52: [20, 40, 80],
+            56: [20, 40, 80],
+            60: [20, 40, 80],
+            64: [20, 40, 80],
+            100: [20, 40, 80],
+            104: [20, 40, 80],
+            108: [20, 40, 80],
+            112: [20, 40, 80],
+            116: [20, 40, 80],
+            120: [20, 40, 80],
+            124: [20, 40, 80],
+            128: [20, 40, 80],
+            132: [20, 40, 80],
+            136: [20, 40, 80],
+            140: [20, 40, 80],
+            144: [20, 40, 80],
+            149: [20, 40, 80],
+            153: [20, 40, 80],
+            157: [20, 40, 80],
+            161: [20, 40, 80],
+            165: [20],
+        },
+    ),
+    "Spain": CountryChannels(
+        country_code="ES",
+        allowed_channels=WORLD_WIDE_2G_CHANNELS
+        | {
+            12: [20],
+            13: [20],
+            36: [20, 40, 80],
+            40: [20, 40, 80],
+            44: [20, 40, 80],
+            48: [20, 40, 80],
+            52: [20, 40, 80],
+            56: [20, 40, 80],
+            60: [20, 40, 80],
+            64: [20, 40, 80],
+            100: [20, 40, 80],
+            104: [20, 40, 80],
+            108: [20, 40, 80],
+            112: [20, 40, 80],
+            116: [20, 40, 80],
+            120: [20, 40, 80],
+            124: [20, 40, 80],
+            128: [20, 40, 80],
+            132: [20, 40, 80],
+            136: [20, 40, 80],
+            140: [20, 40, 80],
+        },
+    ),
+    "Sweden": CountryChannels(
+        country_code="SE",
+        allowed_channels=WORLD_WIDE_2G_CHANNELS
+        | {
+            12: [20],
+            13: [20],
+            36: [20, 40, 80],
+            40: [20, 40, 80],
+            44: [20, 40, 80],
+            48: [20, 40, 80],
+            52: [20, 40, 80],
+            56: [20, 40, 80],
+            60: [20, 40, 80],
+            64: [20, 40, 80],
+            100: [20, 40, 80],
+            104: [20, 40, 80],
+            108: [20, 40, 80],
+            112: [20, 40, 80],
+            116: [20, 40, 80],
+            120: [20, 40, 80],
+            124: [20, 40, 80],
+            128: [20, 40, 80],
+            132: [20, 40, 80],
+            136: [20, 40, 80],
+            140: [20, 40, 80],
+        },
+    ),
+    "Taiwan": CountryChannels(
+        country_code="TW",
+        allowed_channels=WORLD_WIDE_2G_CHANNELS
+        | {
+            12: [20],
+            13: [20],
+            36: [20, 40, 80],
+            40: [20, 40, 80],
+            44: [20, 40, 80],
+            48: [20, 40, 80],
+            52: [20, 40, 80],
+            56: [20, 40, 80],
+            60: [20, 40, 80],
+            64: [20, 40, 80],
+            100: [20, 40, 80],
+            104: [20, 40, 80],
+            108: [20, 40, 80],
+            112: [20, 40, 80],
+            116: [20, 40, 80],
+            120: [20, 40, 80],
+            124: [20, 40, 80],
+            128: [20, 40, 80],
+            132: [20, 40, 80],
+            136: [20, 40, 80],
+            140: [20, 40, 80],
+            144: [20, 40, 80],
+            149: [20, 40, 80],
+            153: [20, 40, 80],
+            157: [20, 40, 80],
+            161: [20, 40, 80],
+            165: [20],
+        },
+    ),
+    "United Kingdom of Great Britain": CountryChannels(
+        country_code="GB",
+        allowed_channels=WORLD_WIDE_2G_CHANNELS
+        | {
+            11: [20],
+            12: [20],
+            13: [20],
+            36: [20, 40, 80],
+            40: [20, 40, 80],
+            44: [20, 40, 80],
+            48: [20, 40, 80],
+            52: [20, 40, 80],
+            56: [20, 40, 80],
+            60: [20, 40, 80],
+            64: [20, 40, 80],
+            100: [20, 40, 80],
+            104: [20, 40, 80],
+            108: [20, 40, 80],
+            112: [20, 40, 80],
+            116: [20, 40, 80],
+            120: [20, 40, 80],
+            124: [20, 40, 80],
+            128: [20, 40, 80],
+            132: [20, 40, 80],
+            136: [20, 40, 80],
+            140: [20, 40, 80],
+        },
+    ),
+    "United States of America": CountryChannels(
+        country_code="US",
+        allowed_channels=WORLD_WIDE_2G_CHANNELS
+        | {
+            36: [20, 40, 80],
+            40: [20, 40, 80],
+            44: [20, 40, 80],
+            48: [20, 40, 80],
+            52: [20, 40, 80],
+            56: [20, 40, 80],
+            60: [20, 40, 80],
+            64: [20, 40, 80],
+            100: [20, 40, 80],
+            104: [20, 40, 80],
+            108: [20, 40, 80],
+            112: [20, 40, 80],
+            116: [20, 40, 80],
+            120: [20, 40, 80],
+            124: [20, 40, 80],
+            128: [20, 40, 80],
+            132: [20, 40, 80],
+            136: [20, 40, 80],
+            140: [20, 40, 80],
+            144: [20, 40, 80],
+            149: [20, 40, 80],
+            153: [20, 40, 80],
+            157: [20, 40, 80],
+            161: [20, 40, 80],
+            165: [20],
+        },
+    ),
+}
diff --git a/src/antlion/controllers/ap_lib/third_party_ap_profiles/__init__.py b/packages/antlion/controllers/ap_lib/third_party_ap_profiles/__init__.py
similarity index 100%
rename from src/antlion/controllers/ap_lib/third_party_ap_profiles/__init__.py
rename to packages/antlion/controllers/ap_lib/third_party_ap_profiles/__init__.py
diff --git a/packages/antlion/controllers/ap_lib/third_party_ap_profiles/actiontec.py b/packages/antlion/controllers/ap_lib/third_party_ap_profiles/actiontec.py
new file mode 100644
index 0000000..f04f60b
--- /dev/null
+++ b/packages/antlion/controllers/ap_lib/third_party_ap_profiles/actiontec.py
@@ -0,0 +1,150 @@
+# 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.
+
+
+from antlion.controllers.ap_lib import hostapd_config, hostapd_constants, hostapd_utils
+from antlion.controllers.ap_lib.hostapd_security import Security, SecurityMode
+
+
+def actiontec_pk5000(
+    iface_wlan_2g: str, channel: int, security: Security, ssid: str | None = None
+) -> hostapd_config.HostapdConfig:
+    """A simulated implementation of what a Actiontec PK5000 AP
+    Args:
+        iface_wlan_2g: The 2.4 interface of the test AP.
+        channel: What channel to use.  Only 2.4Ghz is supported for this profile
+        security: A security profile.  Must be open or WPA2 as this is what is
+            supported by the PK5000.
+        ssid: Network name
+    Returns:
+        A hostapd config
+
+    Differences from real pk5000:
+        Supported Rates IE:
+            PK5000: Supported: 1, 2, 5.5, 11
+                    Extended: 6, 9, 12, 18, 24, 36, 48, 54
+            Simulated: Supported: 1, 2, 5.5, 11, 6, 9, 12, 18
+                       Extended: 24, 36, 48, 54
+    """
+    if channel > 11:
+        # Technically this should be 14 but since the PK5000 is a US only AP,
+        # 11 is the highest allowable channel.
+        raise ValueError(
+            f"The Actiontec PK5000 does not support 5Ghz. Invalid channel ({channel})"
+        )
+    # Verify interface and security
+    hostapd_utils.verify_interface(iface_wlan_2g, hostapd_constants.INTERFACE_2G_LIST)
+    hostapd_utils.verify_security_mode(security, [SecurityMode.OPEN, SecurityMode.WPA2])
+    if security.security_mode is not SecurityMode.OPEN:
+        hostapd_utils.verify_cipher(security, [hostapd_constants.WPA2_DEFAULT_CIPER])
+
+    interface = iface_wlan_2g
+    short_preamble = False
+    force_wmm = False
+    beacon_interval = 100
+    dtim_period = 3
+    # Sets the basic rates and supported rates of the PK5000
+    additional_params = (
+        hostapd_constants.CCK_AND_OFDM_BASIC_RATES
+        | hostapd_constants.CCK_AND_OFDM_DATA_RATES
+    )
+
+    config = hostapd_config.HostapdConfig(
+        ssid=ssid,
+        channel=channel,
+        hidden=False,
+        security=security,
+        interface=interface,
+        mode=hostapd_constants.MODE_11G,
+        force_wmm=force_wmm,
+        beacon_interval=beacon_interval,
+        dtim_period=dtim_period,
+        short_preamble=short_preamble,
+        additional_parameters=additional_params,
+    )
+
+    return config
+
+
+def actiontec_mi424wr(
+    iface_wlan_2g: str, channel: int, security: Security, ssid: str | None = None
+) -> hostapd_config.HostapdConfig:
+    # TODO(b/143104825): Permit RIFS once it is supported
+    """A simulated implementation of an Actiontec MI424WR AP.
+    Args:
+        iface_wlan_2g: The 2.4Ghz interface of the test AP.
+        channel: What channel to use (2.4Ghz or 5Ghz).
+        security: A security profile.
+        ssid: The network name.
+    Returns:
+        A hostapd config.
+
+    Differences from real MI424WR:
+        HT Capabilities:
+            MI424WR:
+                HT Rx STBC: Support for 1, 2, and 3
+            Simulated:
+                HT Rx STBC: Support for 1
+        HT Information:
+            MI424WR:
+                RIFS: Premitted
+            Simulated:
+                RIFS: Prohibited
+    """
+    if channel > 11:
+        raise ValueError(
+            f"The Actiontec MI424WR does not support 5Ghz. Invalid channel ({channel})"
+        )
+    # Verify interface and security
+    hostapd_utils.verify_interface(iface_wlan_2g, hostapd_constants.INTERFACE_2G_LIST)
+    hostapd_utils.verify_security_mode(security, [SecurityMode.OPEN, SecurityMode.WPA2])
+    if security.security_mode is not SecurityMode.OPEN:
+        hostapd_utils.verify_cipher(security, [hostapd_constants.WPA2_DEFAULT_CIPER])
+
+    n_capabilities = [
+        hostapd_constants.N_CAPABILITY_TX_STBC,
+        hostapd_constants.N_CAPABILITY_DSSS_CCK_40,
+        hostapd_constants.N_CAPABILITY_RX_STBC1,
+    ]
+    rates = (
+        hostapd_constants.CCK_AND_OFDM_DATA_RATES
+        | hostapd_constants.CCK_AND_OFDM_BASIC_RATES
+    )
+    # Proprietary Atheros Communication: Adv Capability IE
+    # Proprietary Atheros Communication: Unknown IE
+    # Country Info: US Only IE
+    vendor_elements = {
+        "vendor_elements": "dd0900037f01010000ff7f"
+        "dd0a00037f04010000000000"
+        "0706555320010b1b"
+    }
+
+    additional_params = rates | vendor_elements
+
+    config = hostapd_config.HostapdConfig(
+        ssid=ssid,
+        channel=channel,
+        hidden=False,
+        security=security,
+        interface=iface_wlan_2g,
+        mode=hostapd_constants.MODE_11N_MIXED,
+        force_wmm=True,
+        beacon_interval=100,
+        dtim_period=1,
+        short_preamble=True,
+        n_capabilities=n_capabilities,
+        additional_parameters=additional_params,
+    )
+
+    return config
diff --git a/packages/antlion/controllers/ap_lib/third_party_ap_profiles/asus.py b/packages/antlion/controllers/ap_lib/third_party_ap_profiles/asus.py
new file mode 100644
index 0000000..6a9ae27
--- /dev/null
+++ b/packages/antlion/controllers/ap_lib/third_party_ap_profiles/asus.py
@@ -0,0 +1,554 @@
+# 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.
+
+
+from antlion.controllers.ap_lib import hostapd_config, hostapd_constants, hostapd_utils
+from antlion.controllers.ap_lib.hostapd_security import Security, SecurityMode
+
+
+def asus_rtac66u(
+    iface_wlan_2g: str,
+    iface_wlan_5g: str,
+    channel: int,
+    security: Security,
+    ssid: str | None = None,
+) -> hostapd_config.HostapdConfig:
+    # TODO(b/143104825): Permit RIFS once it is supported
+    # TODO(b/144446076): Address non-whirlwind hardware capabilities.
+    """A simulated implementation of an Asus RTAC66U AP.
+    Args:
+        iface_wlan_2g: The 2.4Ghz interface of the test AP.
+        iface_wlan_5g: The 5Ghz interface of the test AP.
+        channel: What channel to use.
+        security: A security profile.  Must be open or WPA2 as this is what is
+            supported by the RTAC66U.
+        ssid: Network name
+    Returns:
+        A hostapd config
+    Differences from real RTAC66U:
+        2.4 GHz:
+            Rates:
+                RTAC66U:
+                    Supported: 1, 2, 5.5, 11, 18, 24, 36, 54
+                    Extended: 6, 9, 12, 48
+                Simulated:
+                    Supported: 1, 2, 5.5, 11, 6, 9, 12, 18
+                    Extended: 24, 36, 48, 54
+            HT Capab:
+                Info
+                    RTAC66U: Green Field supported
+                    Simulated: Green Field not supported on Whirlwind.
+        5GHz:
+            VHT Capab:
+                RTAC66U:
+                    SU Beamformer Supported,
+                    SU Beamformee Supported,
+                    Beamformee STS Capability: 3,
+                    Number of Sounding Dimensions: 3,
+                    VHT Link Adaptation: Both
+                Simulated:
+                    Above are not supported on Whirlwind.
+            VHT Operation Info:
+                RTAC66U: Basic MCS Map (0x0000)
+                Simulated: Basic MCS Map (0xfffc)
+            VHT Tx Power Envelope:
+                RTAC66U: Local Max Tx Pwr Constraint: 1.0 dBm
+                Simulated: Local Max Tx Pwr Constraint: 23.0 dBm
+        Both:
+            HT Capab:
+                A-MPDU
+                    RTAC66U: MPDU Density 4
+                    Simulated: MPDU Density 8
+            HT Info:
+                RTAC66U: RIFS Permitted
+                Simulated: RIFS Prohibited
+    """
+    # Verify interface and security
+    hostapd_utils.verify_interface(iface_wlan_2g, hostapd_constants.INTERFACE_2G_LIST)
+    hostapd_utils.verify_interface(iface_wlan_5g, hostapd_constants.INTERFACE_5G_LIST)
+    hostapd_utils.verify_security_mode(security, [SecurityMode.OPEN, SecurityMode.WPA2])
+    if security.security_mode is not SecurityMode.OPEN:
+        hostapd_utils.verify_cipher(security, [hostapd_constants.WPA2_DEFAULT_CIPER])
+
+    # Common Parameters
+    rates = hostapd_constants.CCK_AND_OFDM_DATA_RATES
+    vht_channel_width = 20
+    n_capabilities = [
+        hostapd_constants.N_CAPABILITY_LDPC,
+        hostapd_constants.N_CAPABILITY_TX_STBC,
+        hostapd_constants.N_CAPABILITY_RX_STBC1,
+        hostapd_constants.N_CAPABILITY_MAX_AMSDU_7935,
+        hostapd_constants.N_CAPABILITY_DSSS_CCK_40,
+        hostapd_constants.N_CAPABILITY_SGI20,
+    ]
+    # WPS IE
+    # Broadcom IE
+    vendor_elements = {
+        "vendor_elements": "dd310050f204104a00011010440001021047001093689729d373c26cb1563c6c570f33"
+        "d7103c0001031049000600372a000120"
+        "dd090010180200001c0000"
+    }
+
+    # 2.4GHz
+    if channel <= 11:
+        interface = iface_wlan_2g
+        rates.update(hostapd_constants.CCK_AND_OFDM_BASIC_RATES)
+        mode = hostapd_constants.MODE_11N_MIXED
+        ac_capabilities = None
+
+    # 5GHz
+    else:
+        interface = iface_wlan_5g
+        rates.update(hostapd_constants.OFDM_ONLY_BASIC_RATES)
+        mode = hostapd_constants.MODE_11AC_MIXED
+        ac_capabilities = [
+            hostapd_constants.AC_CAPABILITY_RXLDPC,
+            hostapd_constants.AC_CAPABILITY_SHORT_GI_80,
+            hostapd_constants.AC_CAPABILITY_TX_STBC_2BY1,
+            hostapd_constants.AC_CAPABILITY_RX_STBC_1,
+            hostapd_constants.AC_CAPABILITY_MAX_MPDU_11454,
+            hostapd_constants.AC_CAPABILITY_MAX_A_MPDU_LEN_EXP7,
+        ]
+
+    additional_params = rates | vendor_elements | hostapd_constants.UAPSD_ENABLED
+
+    config = hostapd_config.HostapdConfig(
+        ssid=ssid,
+        channel=channel,
+        hidden=False,
+        security=security,
+        interface=interface,
+        mode=mode,
+        force_wmm=True,
+        beacon_interval=100,
+        dtim_period=3,
+        short_preamble=False,
+        n_capabilities=n_capabilities,
+        ac_capabilities=ac_capabilities,
+        vht_channel_width=vht_channel_width,
+        additional_parameters=additional_params,
+    )
+
+    return config
+
+
+def asus_rtac86u(
+    iface_wlan_2g: str,
+    iface_wlan_5g: str,
+    channel: int,
+    security: Security,
+    ssid: str | None = None,
+) -> hostapd_config.HostapdConfig:
+    """A simulated implementation of an Asus RTAC86U AP.
+    Args:
+        iface_wlan_2g: The 2.4Ghz interface of the test AP.
+        iface_wlan_5g: The 5Ghz interface of the test AP.
+        channel: What channel to use.
+        security: A security profile.  Must be open or WPA2 as this is what is
+            supported by the RTAC86U.
+        ssid: Network name
+    Returns:
+        A hostapd config
+    Differences from real RTAC86U:
+        2.4GHz:
+            Rates:
+                RTAC86U:
+                    Supported: 1, 2, 5.5, 11, 18, 24, 36, 54
+                    Extended: 6, 9, 12, 48
+                Simulated:
+                    Supported: 1, 2, 5.5, 11, 6, 9, 12, 18
+                    Extended: 24, 36, 48, 54
+        5GHz:
+            Country Code:
+                Simulated: Has two country code IEs, one that matches
+                the actual, and another explicit IE that was required for
+                hostapd's 802.11d to work.
+        Both:
+            RSN Capabilities (w/ WPA2):
+                RTAC86U:
+                    RSN PTKSA Replay Counter Capab: 16
+                Simulated:
+                    RSN PTKSA Replay Counter Capab: 1
+    """
+    # Verify interface and security
+    hostapd_utils.verify_interface(iface_wlan_2g, hostapd_constants.INTERFACE_2G_LIST)
+    hostapd_utils.verify_interface(iface_wlan_5g, hostapd_constants.INTERFACE_5G_LIST)
+    hostapd_utils.verify_security_mode(security, [SecurityMode.OPEN, SecurityMode.WPA2])
+    if security.security_mode is not SecurityMode.OPEN:
+        hostapd_utils.verify_cipher(security, [hostapd_constants.WPA2_DEFAULT_CIPER])
+
+    # Common Parameters
+    rates = hostapd_constants.CCK_AND_OFDM_DATA_RATES
+    qbss = {"bss_load_update_period": 50, "chan_util_avg_period": 600}
+
+    # 2.4GHz
+    if channel <= 11:
+        interface = iface_wlan_2g
+        mode = hostapd_constants.MODE_11G
+        rates.update(hostapd_constants.CCK_AND_OFDM_BASIC_RATES)
+        spectrum_mgmt = False
+        # Measurement Pilot Transmission IE
+        vendor_elements = {"vendor_elements": "42020000"}
+
+    # 5GHz
+    else:
+        interface = iface_wlan_5g
+        mode = hostapd_constants.MODE_11A
+        rates.update(hostapd_constants.OFDM_ONLY_BASIC_RATES)
+        spectrum_mgmt = True
+        # Country Information IE (w/ individual channel info)
+        # TPC Report Transmit Power IE
+        # Measurement Pilot Transmission IE
+        vendor_elements = {
+            "vendor_elements": "074255532024011e28011e2c011e30011e34011e38011e3c011e40011e64011e"
+            "68011e6c011e70011e74011e84011e88011e8c011e95011e99011e9d011ea1011e"
+            "a5011e"
+            "23021300"
+            "42020000"
+        }
+
+    additional_params = rates | qbss | vendor_elements
+
+    config = hostapd_config.HostapdConfig(
+        ssid=ssid,
+        channel=channel,
+        hidden=False,
+        security=security,
+        interface=interface,
+        mode=mode,
+        force_wmm=False,
+        beacon_interval=100,
+        dtim_period=3,
+        short_preamble=False,
+        spectrum_mgmt_required=spectrum_mgmt,
+        additional_parameters=additional_params,
+    )
+    return config
+
+
+def asus_rtac5300(
+    iface_wlan_2g: str,
+    iface_wlan_5g: str,
+    channel: int,
+    security: Security,
+    ssid: str | None = None,
+) -> hostapd_config.HostapdConfig:
+    # TODO(b/143104825): Permit RIFS once it is supported
+    # TODO(b/144446076): Address non-whirlwind hardware capabilities.
+    """A simulated implementation of an Asus RTAC5300 AP.
+    Args:
+        iface_wlan_2g: The 2.4Ghz interface of the test AP.
+        iface_wlan_5g: The 5Ghz interface of the test AP.
+        channel: What channel to use.
+        security: A security profile.  Must be open or WPA2 as this is what is
+            supported by the RTAC5300.
+        ssid: Network name
+    Returns:
+        A hostapd config
+    Differences from real RTAC5300:
+        2.4GHz:
+            Rates:
+                RTAC86U:
+                    Supported: 1, 2, 5.5, 11, 18, 24, 36, 54
+                    Extended: 6, 9, 12, 48
+                Simulated:
+                    Supported: 1, 2, 5.5, 11, 6, 9, 12, 18
+                    Extended: 24, 36, 48, 54
+        5GHz:
+            VHT Capab:
+                RTAC5300:
+                    SU Beamformer Supported,
+                    SU Beamformee Supported,
+                    Beamformee STS Capability: 4,
+                    Number of Sounding Dimensions: 4,
+                    MU Beamformer Supported,
+                    VHT Link Adaptation: Both
+                Simulated:
+                    Above are not supported on Whirlwind.
+            VHT Operation Info:
+                RTAC5300: Basic MCS Map (0x0000)
+                Simulated: Basic MCS Map (0xfffc)
+            VHT Tx Power Envelope:
+                RTAC5300: Local Max Tx Pwr Constraint: 1.0 dBm
+                Simulated: Local Max Tx Pwr Constraint: 23.0 dBm
+        Both:
+            HT Capab:
+                A-MPDU
+                    RTAC5300: MPDU Density 4
+                    Simulated: MPDU Density 8
+            HT Info:
+                RTAC5300: RIFS Permitted
+                Simulated: RIFS Prohibited
+    """
+    # Verify interface and security
+    hostapd_utils.verify_interface(iface_wlan_2g, hostapd_constants.INTERFACE_2G_LIST)
+    hostapd_utils.verify_interface(iface_wlan_5g, hostapd_constants.INTERFACE_5G_LIST)
+    hostapd_utils.verify_security_mode(security, [SecurityMode.OPEN, SecurityMode.WPA2])
+    if security.security_mode is not SecurityMode.OPEN:
+        hostapd_utils.verify_cipher(security, [hostapd_constants.WPA2_DEFAULT_CIPER])
+
+    # Common Parameters
+    rates = hostapd_constants.CCK_AND_OFDM_DATA_RATES
+    vht_channel_width = 20
+    qbss = {"bss_load_update_period": 50, "chan_util_avg_period": 600}
+    n_capabilities = [
+        hostapd_constants.N_CAPABILITY_LDPC,
+        hostapd_constants.N_CAPABILITY_TX_STBC,
+        hostapd_constants.N_CAPABILITY_RX_STBC1,
+        hostapd_constants.N_CAPABILITY_SGI20,
+    ]
+
+    # Broadcom IE
+    vendor_elements = {"vendor_elements": "dd090010180200009c0000"}
+
+    # 2.4GHz
+    if channel <= 11:
+        interface = iface_wlan_2g
+        rates.update(hostapd_constants.CCK_AND_OFDM_BASIC_RATES)
+        mode = hostapd_constants.MODE_11N_MIXED
+        # AsusTek IE
+        # Epigram 2.4GHz IE
+        vendor_elements["vendor_elements"] += (
+            "dd25f832e4010101020100031411b5"
+            "2fd437509c30b3d7f5cf5754fb125aed3b8507045aed3b85"
+            "dd1e00904c0418bf0cb2798b0faaff0000aaff0000c0050001000000c3020002"
+        )
+        ac_capabilities = None
+
+    # 5GHz
+    else:
+        interface = iface_wlan_5g
+        rates.update(hostapd_constants.OFDM_ONLY_BASIC_RATES)
+        mode = hostapd_constants.MODE_11AC_MIXED
+        # Epigram 5GHz IE
+        vendor_elements["vendor_elements"] += "dd0500904c0410"
+        ac_capabilities = [
+            hostapd_constants.AC_CAPABILITY_RXLDPC,
+            hostapd_constants.AC_CAPABILITY_SHORT_GI_80,
+            hostapd_constants.AC_CAPABILITY_TX_STBC_2BY1,
+            hostapd_constants.AC_CAPABILITY_RX_STBC_1,
+            hostapd_constants.AC_CAPABILITY_MAX_MPDU_11454,
+            hostapd_constants.AC_CAPABILITY_MAX_A_MPDU_LEN_EXP7,
+        ]
+
+    additional_params = rates | qbss | vendor_elements | hostapd_constants.UAPSD_ENABLED
+
+    config = hostapd_config.HostapdConfig(
+        ssid=ssid,
+        channel=channel,
+        hidden=False,
+        security=security,
+        interface=interface,
+        mode=mode,
+        force_wmm=True,
+        beacon_interval=100,
+        dtim_period=3,
+        short_preamble=False,
+        n_capabilities=n_capabilities,
+        ac_capabilities=ac_capabilities,
+        vht_channel_width=vht_channel_width,
+        additional_parameters=additional_params,
+    )
+    return config
+
+
+def asus_rtn56u(
+    iface_wlan_2g: str,
+    iface_wlan_5g: str,
+    channel: int,
+    security: Security,
+    ssid: str | None = None,
+) -> hostapd_config.HostapdConfig:
+    """A simulated implementation of an Asus RTN56U AP.
+    Args:
+        iface_wlan_2g: The 2.4Ghz interface of the test AP.
+        iface_wlan_5g: The 5Ghz interface of the test AP.
+        channel: What channel to use.
+        security: A security profile.  Must be open or WPA2 as this is what is
+            supported by the RTN56U.
+        ssid: Network name
+    Returns:
+        A hostapd config
+    Differences from real RTN56U:
+        2.4GHz:
+            Rates:
+                RTN56U:
+                    Supported: 1, 2, 5.5, 11, 18, 24, 36, 54
+                    Extended: 6, 9, 12, 48
+                Simulated:
+                    Supported: 1, 2, 5.5, 11, 6, 9, 12, 18
+                    Extended: 24, 36, 48, 54
+        Both:
+            Fixed Parameters:
+                RTN56U: APSD Implemented
+                Simulated: APSD Not Implemented
+            HT Capab:
+                A-MPDU
+                    RTN56U: MPDU Density 4
+                    Simulated: MPDU Density 8
+            RSN Capabilities (w/ WPA2):
+                RTN56U:
+                    RSN PTKSA Replay Counter Capab: 1
+                Simulated:
+                    RSN PTKSA Replay Counter Capab: 16
+    """
+    # Verify interface and security
+    hostapd_utils.verify_interface(iface_wlan_2g, hostapd_constants.INTERFACE_2G_LIST)
+    hostapd_utils.verify_interface(iface_wlan_5g, hostapd_constants.INTERFACE_5G_LIST)
+    hostapd_utils.verify_security_mode(security, [SecurityMode.OPEN, SecurityMode.WPA2])
+    if security.security_mode is not SecurityMode.OPEN:
+        hostapd_utils.verify_cipher(security, [hostapd_constants.WPA2_DEFAULT_CIPER])
+
+    # Common Parameters
+    rates = hostapd_constants.CCK_AND_OFDM_DATA_RATES
+    qbss = {"bss_load_update_period": 50, "chan_util_avg_period": 600}
+    n_capabilities = [
+        hostapd_constants.N_CAPABILITY_SGI20,
+        hostapd_constants.N_CAPABILITY_SGI40,
+        hostapd_constants.N_CAPABILITY_TX_STBC,
+        hostapd_constants.N_CAPABILITY_RX_STBC1,
+    ]
+
+    # 2.4GHz
+    if channel <= 11:
+        interface = iface_wlan_2g
+        rates.update(hostapd_constants.CCK_AND_OFDM_BASIC_RATES)
+        # Ralink Technology IE
+        # US Country Code IE
+        # AP Channel Report IEs (2)
+        # WPS IE
+        vendor_elements = {
+            "vendor_elements": "dd07000c4307000000"
+            "0706555320010b14"
+            "33082001020304050607"
+            "33082105060708090a0b"
+            "dd270050f204104a000110104400010210470010bc329e001dd811b286011c872c"
+            "d33448103c000101"
+        }
+
+    # 5GHz
+    else:
+        interface = iface_wlan_5g
+        rates.update(hostapd_constants.OFDM_ONLY_BASIC_RATES)
+        # Ralink Technology IE
+        # US Country Code IE
+        vendor_elements = {"vendor_elements": "dd07000c4307000000" "0706555320010b14"}
+
+    additional_params = rates | vendor_elements | qbss | hostapd_constants.UAPSD_ENABLED
+
+    config = hostapd_config.HostapdConfig(
+        ssid=ssid,
+        channel=channel,
+        hidden=False,
+        security=security,
+        interface=interface,
+        mode=hostapd_constants.MODE_11N_MIXED,
+        force_wmm=True,
+        beacon_interval=100,
+        dtim_period=1,
+        short_preamble=False,
+        n_capabilities=n_capabilities,
+        additional_parameters=additional_params,
+    )
+
+    return config
+
+
+def asus_rtn66u(
+    iface_wlan_2g: str,
+    iface_wlan_5g: str,
+    channel: int,
+    security: Security,
+    ssid: str | None = None,
+) -> hostapd_config.HostapdConfig:
+    # TODO(b/143104825): Permit RIFS once it is supported
+    """A simulated implementation of an Asus RTN66U AP.
+    Args:
+        iface_wlan_2g: The 2.4Ghz interface of the test AP.
+        iface_wlan_5g: The 5Ghz interface of the test AP.
+        channel: What channel to use.
+        security: A security profile.  Must be open or WPA2 as this is what is
+            supported by the RTN66U.
+        ssid: Network name
+    Returns:
+        A hostapd config
+    Differences from real RTN66U:
+        2.4GHz:
+            Rates:
+                RTN66U:
+                    Supported: 1, 2, 5.5, 11, 18, 24, 36, 54
+                    Extended: 6, 9, 12, 48
+                Simulated:
+                    Supported: 1, 2, 5.5, 11, 6, 9, 12, 18
+                    Extended: 24, 36, 48, 54
+        Both:
+            HT Info:
+                RTN66U: RIFS Permitted
+                Simulated: RIFS Prohibited
+            HT Capab:
+                Info:
+                    RTN66U: Green Field supported
+                    Simulated: Green Field not supported on Whirlwind.
+                A-MPDU
+                    RTN66U: MPDU Density 4
+                    Simulated: MPDU Density 8
+    """
+    # Verify interface and security
+    hostapd_utils.verify_interface(iface_wlan_2g, hostapd_constants.INTERFACE_2G_LIST)
+    hostapd_utils.verify_interface(iface_wlan_5g, hostapd_constants.INTERFACE_5G_LIST)
+    hostapd_utils.verify_security_mode(security, [SecurityMode.OPEN, SecurityMode.WPA2])
+    if security.security_mode is not SecurityMode.OPEN:
+        hostapd_utils.verify_cipher(security, [hostapd_constants.WPA2_DEFAULT_CIPER])
+
+    # Common Parameters
+    rates = hostapd_constants.CCK_AND_OFDM_DATA_RATES
+    n_capabilities = [
+        hostapd_constants.N_CAPABILITY_LDPC,
+        hostapd_constants.N_CAPABILITY_SGI20,
+        hostapd_constants.N_CAPABILITY_TX_STBC,
+        hostapd_constants.N_CAPABILITY_RX_STBC1,
+        hostapd_constants.N_CAPABILITY_MAX_AMSDU_7935,
+    ]
+    # Broadcom IE
+    vendor_elements = {"vendor_elements": "dd090010180200001c0000"}
+
+    # 2.4GHz
+    if channel <= 11:
+        interface = iface_wlan_2g
+        rates.update(hostapd_constants.CCK_AND_OFDM_BASIC_RATES)
+        n_capabilities.append(hostapd_constants.N_CAPABILITY_DSSS_CCK_40)
+
+    # 5GHz
+    else:
+        interface = iface_wlan_5g
+        rates.update(hostapd_constants.OFDM_ONLY_BASIC_RATES)
+
+    additional_params = rates | vendor_elements | hostapd_constants.UAPSD_ENABLED
+
+    config = hostapd_config.HostapdConfig(
+        ssid=ssid,
+        channel=channel,
+        hidden=False,
+        security=security,
+        interface=interface,
+        mode=hostapd_constants.MODE_11N_MIXED,
+        force_wmm=True,
+        beacon_interval=100,
+        dtim_period=3,
+        short_preamble=False,
+        n_capabilities=n_capabilities,
+        additional_parameters=additional_params,
+    )
+
+    return config
diff --git a/packages/antlion/controllers/ap_lib/third_party_ap_profiles/belkin.py b/packages/antlion/controllers/ap_lib/third_party_ap_profiles/belkin.py
new file mode 100644
index 0000000..62a9d66
--- /dev/null
+++ b/packages/antlion/controllers/ap_lib/third_party_ap_profiles/belkin.py
@@ -0,0 +1,98 @@
+# 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.
+
+
+from antlion.controllers.ap_lib import hostapd_config, hostapd_constants, hostapd_utils
+from antlion.controllers.ap_lib.hostapd_security import Security, SecurityMode
+
+
+def belkin_f9k1001v5(
+    iface_wlan_2g: str, channel: int, security: Security, ssid: str | None = None
+) -> hostapd_config.HostapdConfig:
+    # TODO(b/143104825): Permit RIFS once it is supported
+    """A simulated implementation of what a Belkin F9K1001v5 AP
+    Args:
+        iface_wlan_2g: The 2.4Ghz interface of the test AP.
+        channel: What channel to use.
+        security: A security profile (open or WPA2).
+        ssid: The network name.
+    Returns:
+        A hostapd config.
+    Differences from real F9K1001v5:
+        Rates:
+            F9K1001v5:
+                Supported: 1, 2, 5.5, 11, 18, 24, 36, 54
+                Extended: 6, 9, 12, 48
+            Simulated:
+                Supported: 1, 2, 5.5, 11, 6, 9, 12, 18
+                Extended: 24, 36, 48, 54
+        HT Info:
+            F9K1001v5:
+                RIFS: Permitted
+            Simulated:
+                RIFS: Prohibited
+        RSN Capabilities (w/ WPA2):
+            F9K1001v5:
+                RSN PTKSA Replay Counter Capab: 1
+            Simulated:
+                RSN PTKSA Replay Counter Capab: 16
+    """
+    if channel > 11:
+        raise ValueError(
+            f"The Belkin F9k1001v5 does not support 5Ghz. Invalid channel ({channel})"
+        )
+    # Verify interface and security
+    hostapd_utils.verify_interface(iface_wlan_2g, hostapd_constants.INTERFACE_2G_LIST)
+    hostapd_utils.verify_security_mode(security, [SecurityMode.OPEN, SecurityMode.WPA2])
+    if security.security_mode is not SecurityMode.OPEN:
+        hostapd_utils.verify_cipher(security, [hostapd_constants.WPA2_DEFAULT_CIPER])
+
+    n_capabilities = [
+        hostapd_constants.N_CAPABILITY_SGI20,
+        hostapd_constants.N_CAPABILITY_SGI40,
+        hostapd_constants.N_CAPABILITY_TX_STBC,
+        hostapd_constants.N_CAPABILITY_MAX_AMSDU_7935,
+        hostapd_constants.N_CAPABILITY_DSSS_CCK_40,
+    ]
+
+    rates = (
+        hostapd_constants.CCK_AND_OFDM_BASIC_RATES
+        | hostapd_constants.CCK_AND_OFDM_DATA_RATES
+    )
+
+    # Broadcom IE
+    # WPS IE
+    vendor_elements = {
+        "vendor_elements": "dd090010180200100c0000"
+        "dd180050f204104a00011010440001021049000600372a000120"
+    }
+
+    additional_params = rates | vendor_elements
+
+    config = hostapd_config.HostapdConfig(
+        ssid=ssid,
+        channel=channel,
+        hidden=False,
+        security=security,
+        interface=iface_wlan_2g,
+        mode=hostapd_constants.MODE_11N_MIXED,
+        force_wmm=True,
+        beacon_interval=100,
+        dtim_period=3,
+        short_preamble=False,
+        n_capabilities=n_capabilities,
+        additional_parameters=additional_params,
+    )
+
+    return config
diff --git a/packages/antlion/controllers/ap_lib/third_party_ap_profiles/linksys.py b/packages/antlion/controllers/ap_lib/third_party_ap_profiles/linksys.py
new file mode 100644
index 0000000..21f3fb1
--- /dev/null
+++ b/packages/antlion/controllers/ap_lib/third_party_ap_profiles/linksys.py
@@ -0,0 +1,305 @@
+# 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.
+
+
+from antlion.controllers.ap_lib import hostapd_config, hostapd_constants, hostapd_utils
+from antlion.controllers.ap_lib.hostapd_security import Security, SecurityMode
+
+
+def linksys_ea4500(
+    iface_wlan_2g: str,
+    iface_wlan_5g: str,
+    channel: int,
+    security: Security,
+    ssid: str | None = None,
+) -> hostapd_config.HostapdConfig:
+    # TODO(b/143104825): Permit RIFS once it is supported
+    # TODO(b/144446076): Address non-whirlwind hardware capabilities.
+    """A simulated implementation of what a Linksys EA4500 AP
+    Args:
+        iface_wlan_2g: The 2.4Ghz interface of the test AP.
+        iface_wlan_5g: The 5GHz interface of the test AP.
+        channel: What channel to use.
+        security: A security profile (open or WPA2).
+        ssid: The network name.
+    Returns:
+        A hostapd config.
+    Differences from real EA4500:
+        CF (Contention-Free) Parameter IE:
+            EA4500: has CF Parameter IE
+            Simulated: does not have CF Parameter IE
+        HT Capab:
+            Info:
+                EA4500: Green Field supported
+                Simulated: Green Field not supported on Whirlwind.
+            A-MPDU
+                RTAC66U: MPDU Density 4
+                Simulated: MPDU Density 8
+        RSN Capab (w/ WPA2):
+            EA4500:
+                RSN PTKSA Replay Counter Capab: 1
+            Simulated:
+                RSN PTKSA Replay Counter Capab: 16
+    """
+    # Verify interface and security
+    hostapd_utils.verify_interface(iface_wlan_2g, hostapd_constants.INTERFACE_2G_LIST)
+    hostapd_utils.verify_interface(iface_wlan_5g, hostapd_constants.INTERFACE_5G_LIST)
+    hostapd_utils.verify_security_mode(security, [SecurityMode.OPEN, SecurityMode.WPA2])
+    if security.security_mode is not SecurityMode.OPEN:
+        hostapd_utils.verify_cipher(security, [hostapd_constants.WPA2_DEFAULT_CIPER])
+
+    # Common Parameters
+    rates = hostapd_constants.CCK_AND_OFDM_DATA_RATES
+
+    n_capabilities = [
+        hostapd_constants.N_CAPABILITY_SGI20,
+        hostapd_constants.N_CAPABILITY_SGI40,
+        hostapd_constants.N_CAPABILITY_TX_STBC,
+        hostapd_constants.N_CAPABILITY_RX_STBC1,
+        hostapd_constants.N_CAPABILITY_DSSS_CCK_40,
+    ]
+
+    # Epigram HT Capabilities IE
+    # Epigram HT Additional Capabilities IE
+    # Marvell Semiconductor, Inc. IE
+    vendor_elements = {
+        "vendor_elements": "dd1e00904c33fc0117ffffff0000000000000000000000000000000000000000"
+        "dd1a00904c3424000000000000000000000000000000000000000000"
+        "dd06005043030000"
+    }
+
+    # 2.4GHz
+    if channel <= 11:
+        interface = iface_wlan_2g
+        rates.update(hostapd_constants.CCK_AND_OFDM_BASIC_RATES)
+        obss_interval = 180
+        n_capabilities.append(hostapd_constants.N_CAPABILITY_HT40_PLUS)
+
+    # 5GHz
+    else:
+        interface = iface_wlan_5g
+        rates.update(hostapd_constants.OFDM_ONLY_BASIC_RATES)
+        obss_interval = None
+
+    additional_params = rates | vendor_elements | hostapd_constants.UAPSD_ENABLED
+
+    config = hostapd_config.HostapdConfig(
+        ssid=ssid,
+        channel=channel,
+        hidden=False,
+        security=security,
+        interface=interface,
+        mode=hostapd_constants.MODE_11N_MIXED,
+        force_wmm=True,
+        beacon_interval=100,
+        dtim_period=1,
+        short_preamble=True,
+        obss_interval=obss_interval,
+        n_capabilities=n_capabilities,
+        additional_parameters=additional_params,
+    )
+
+    return config
+
+
+def linksys_ea9500(
+    iface_wlan_2g: str,
+    iface_wlan_5g: str,
+    channel: int,
+    security: Security,
+    ssid: str | None = None,
+) -> hostapd_config.HostapdConfig:
+    """A simulated implementation of what a Linksys EA9500 AP
+    Args:
+        iface_wlan_2g: The 2.4Ghz interface of the test AP.
+        iface_wlan_5g: The 5GHz interface of the test AP.
+        channel: What channel to use.
+        security: A security profile (open or WPA2).
+        ssid: The network name.
+    Returns:
+        A hostapd config.
+    Differences from real EA9500:
+        2.4GHz:
+            Rates:
+                EA9500:
+                    Supported: 1, 2, 5.5, 11, 18, 24, 36, 54
+                    Extended: 6, 9, 12, 48
+                Simulated:
+                    Supported: 1, 2, 5.5, 11, 6, 9, 12, 18
+                    Extended: 24, 36, 48, 54
+        RSN Capab (w/ WPA2):
+            EA9500:
+                RSN PTKSA Replay Counter Capab: 16
+            Simulated:
+                RSN PTKSA Replay Counter Capab: 1
+    """
+    # Verify interface and security
+    hostapd_utils.verify_interface(iface_wlan_2g, hostapd_constants.INTERFACE_2G_LIST)
+    hostapd_utils.verify_interface(iface_wlan_5g, hostapd_constants.INTERFACE_5G_LIST)
+    hostapd_utils.verify_security_mode(security, [SecurityMode.OPEN, SecurityMode.WPA2])
+    if security.security_mode is not SecurityMode.OPEN:
+        hostapd_utils.verify_cipher(security, [hostapd_constants.WPA2_DEFAULT_CIPER])
+
+    # Common Parameters
+    rates = hostapd_constants.CCK_AND_OFDM_DATA_RATES
+    qbss = {"bss_load_update_period": 50, "chan_util_avg_period": 600}
+    # Measurement Pilot Transmission IE
+    vendor_elements = {"vendor_elements": "42020000"}
+
+    # 2.4GHz
+    if channel <= 11:
+        interface = iface_wlan_2g
+        mode = hostapd_constants.MODE_11G
+        rates.update(hostapd_constants.CCK_AND_OFDM_BASIC_RATES)
+
+    # 5GHz
+    else:
+        interface = iface_wlan_5g
+        mode = hostapd_constants.MODE_11A
+        rates.update(hostapd_constants.OFDM_ONLY_BASIC_RATES)
+
+    additional_params = rates | qbss | vendor_elements
+
+    config = hostapd_config.HostapdConfig(
+        ssid=ssid,
+        channel=channel,
+        hidden=False,
+        security=security,
+        interface=interface,
+        mode=mode,
+        force_wmm=False,
+        beacon_interval=100,
+        dtim_period=1,
+        short_preamble=False,
+        additional_parameters=additional_params,
+    )
+    return config
+
+
+def linksys_wrt1900acv2(
+    iface_wlan_2g: str,
+    iface_wlan_5g: str,
+    channel: int,
+    security: Security,
+    ssid: str | None = None,
+) -> hostapd_config.HostapdConfig:
+    # TODO(b/144446076): Address non-whirlwind hardware capabilities.
+    """A simulated implementation of what a Linksys WRT1900ACV2 AP
+    Args:
+        iface_wlan_2g: The 2.4Ghz interface of the test AP.
+        iface_wlan_5g: The 5GHz interface of the test AP.
+        channel: What channel to use.
+        security: A security profile (open or WPA2).
+        ssid: The network name.
+    Returns:
+        A hostapd config.
+    Differences from real WRT1900ACV2:
+        5 GHz:
+            Simulated: Has two country code IEs, one that matches
+                the actual, and another explicit IE that was required for
+                hostapd's 802.11d to work.
+        Both:
+            HT Capab:
+                A-MPDU
+                    WRT1900ACV2: MPDU Density 4
+                    Simulated: MPDU Density 8
+            VHT Capab:
+                WRT1900ACV2:
+                    SU Beamformer Supported,
+                    SU Beamformee Supported,
+                    Beamformee STS Capability: 4,
+                    Number of Sounding Dimensions: 4,
+                Simulated:
+                    Above are not supported on Whirlwind.
+            RSN Capabilities (w/ WPA2):
+                WRT1900ACV2:
+                    RSN PTKSA Replay Counter Capab: 1
+                Simulated:
+                    RSN PTKSA Replay Counter Capab: 16
+    """
+    # Verify interface and security
+    hostapd_utils.verify_interface(iface_wlan_2g, hostapd_constants.INTERFACE_2G_LIST)
+    hostapd_utils.verify_interface(iface_wlan_5g, hostapd_constants.INTERFACE_5G_LIST)
+    hostapd_utils.verify_security_mode(security, [SecurityMode.OPEN, SecurityMode.WPA2])
+    if security.security_mode is not SecurityMode.OPEN:
+        hostapd_utils.verify_cipher(security, [hostapd_constants.WPA2_DEFAULT_CIPER])
+
+    # Common Parameters
+    rates = hostapd_constants.CCK_AND_OFDM_DATA_RATES
+    n_capabilities = [
+        hostapd_constants.N_CAPABILITY_LDPC,
+        hostapd_constants.N_CAPABILITY_SGI20,
+        hostapd_constants.N_CAPABILITY_SGI40,
+    ]
+    ac_capabilities = [
+        hostapd_constants.AC_CAPABILITY_RXLDPC,
+        hostapd_constants.AC_CAPABILITY_SHORT_GI_80,
+        hostapd_constants.AC_CAPABILITY_RX_STBC_1,
+        hostapd_constants.AC_CAPABILITY_RX_ANTENNA_PATTERN,
+        hostapd_constants.AC_CAPABILITY_TX_ANTENNA_PATTERN,
+        hostapd_constants.AC_CAPABILITY_MAX_A_MPDU_LEN_EXP7,
+    ]
+    vht_channel_width = 20
+    # Epigram, Inc. HT Capabilities IE
+    # Epigram, Inc. HT Additional Capabilities IE
+    # Marvell Semiconductor IE
+    vendor_elements = {
+        "vendor_elements": "dd1e00904c336c0017ffffff0001000000000000000000000000001fff071800"
+        "dd1a00904c3424000000000000000000000000000000000000000000"
+        "dd06005043030000"
+    }
+
+    # 2.4GHz
+    if channel <= 11:
+        interface = iface_wlan_2g
+        rates.update(hostapd_constants.CCK_AND_OFDM_BASIC_RATES)
+        obss_interval = 180
+        spectrum_mgmt = False
+        local_pwr_constraint = {}
+
+    # 5GHz
+    else:
+        interface = iface_wlan_5g
+        rates.update(hostapd_constants.OFDM_ONLY_BASIC_RATES)
+        obss_interval = None
+        spectrum_mgmt = True
+        local_pwr_constraint = {"local_pwr_constraint": 3}
+        # Country Information IE (w/ individual channel info)
+        vendor_elements["vendor_elements"] += (
+            "071e5553202401112801112c011130" "01119501179901179d0117a10117a50117"
+        )
+
+    additional_params = (
+        rates | vendor_elements | hostapd_constants.UAPSD_ENABLED | local_pwr_constraint
+    )
+
+    config = hostapd_config.HostapdConfig(
+        ssid=ssid,
+        channel=channel,
+        hidden=False,
+        security=security,
+        interface=interface,
+        mode=hostapd_constants.MODE_11AC_MIXED,
+        force_wmm=True,
+        beacon_interval=100,
+        dtim_period=1,
+        short_preamble=True,
+        obss_interval=obss_interval,
+        n_capabilities=n_capabilities,
+        ac_capabilities=ac_capabilities,
+        vht_channel_width=vht_channel_width,
+        spectrum_mgmt_required=spectrum_mgmt,
+        additional_parameters=additional_params,
+    )
+    return config
diff --git a/packages/antlion/controllers/ap_lib/third_party_ap_profiles/netgear.py b/packages/antlion/controllers/ap_lib/third_party_ap_profiles/netgear.py
new file mode 100644
index 0000000..69c1845
--- /dev/null
+++ b/packages/antlion/controllers/ap_lib/third_party_ap_profiles/netgear.py
@@ -0,0 +1,268 @@
+# 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.
+
+
+from antlion.controllers.ap_lib import hostapd_config, hostapd_constants, hostapd_utils
+from antlion.controllers.ap_lib.hostapd_security import Security, SecurityMode
+
+
+def netgear_r7000(
+    iface_wlan_2g: str,
+    iface_wlan_5g: str,
+    channel: int,
+    security: Security,
+    ssid: str | None = None,
+) -> hostapd_config.HostapdConfig:
+    # TODO(b/143104825): Permit RIFS once it is supported
+    # TODO(b/144446076): Address non-whirlwind hardware capabilities.
+    """A simulated implementation of what a Netgear R7000 AP
+    Args:
+        iface_wlan_2g: The 2.4Ghz interface of the test AP.
+        iface_wlan_5g: The 5GHz interface of the test AP.
+        channel: What channel to use.
+        security: A security profile (open or WPA2).
+        ssid: The network name.
+    Returns:
+        A hostapd config.
+    Differences from real R7000:
+        2.4GHz:
+            Rates:
+                R7000:
+                    Supported: 1, 2, 5.5, 11, 18, 24, 36, 54
+                    Extended: 6, 9, 12, 48
+                Simulated:
+                    Supported: 1, 2, 5.5, 11, 6, 9, 12, 18
+                    Extended: 24, 36, 48,
+        5GHz:
+            VHT Capab:
+                R7000:
+                    SU Beamformer Supported,
+                    SU Beamformee Supported,
+                    Beamformee STS Capability: 3,
+                    Number of Sounding Dimensions: 3,
+                    VHT Link Adaptation: Both
+                Simulated:
+                    Above are not supported on Whirlwind.
+            VHT Operation Info:
+                R7000: Basic MCS Map (0x0000)
+                Simulated: Basic MCS Map (0xfffc)
+            VHT Tx Power Envelope:
+                R7000: Local Max Tx Pwr Constraint: 1.0 dBm
+                Simulated: Local Max Tx Pwr Constraint: 23.0 dBm
+        Both:
+            HT Capab:
+                A-MPDU
+                    R7000: MPDU Density 4
+                    Simulated: MPDU Density 8
+            HT Info:
+                R7000: RIFS Permitted
+                Simulated: RIFS Prohibited
+            RM Capabilities:
+                R7000:
+                    Beacon Table Measurement: Not Supported
+                    Statistic Measurement: Enabled
+                    AP Channel Report Capability: Enabled
+                Simulated:
+                    Beacon Table Measurement: Supported
+                    Statistic Measurement: Disabled
+                    AP Channel Report Capability: Disabled
+    """
+    # Verify interface and security
+    hostapd_utils.verify_interface(iface_wlan_2g, hostapd_constants.INTERFACE_2G_LIST)
+    hostapd_utils.verify_interface(iface_wlan_5g, hostapd_constants.INTERFACE_5G_LIST)
+    hostapd_utils.verify_security_mode(security, [SecurityMode.OPEN, SecurityMode.WPA2])
+    if security.security_mode is not SecurityMode.OPEN:
+        hostapd_utils.verify_cipher(security, [hostapd_constants.WPA2_DEFAULT_CIPER])
+
+    # Common Parameters
+    rates = hostapd_constants.CCK_AND_OFDM_DATA_RATES
+    vht_channel_width = 80
+    n_capabilities = [
+        hostapd_constants.N_CAPABILITY_LDPC,
+        hostapd_constants.N_CAPABILITY_TX_STBC,
+        hostapd_constants.N_CAPABILITY_RX_STBC1,
+        hostapd_constants.N_CAPABILITY_MAX_AMSDU_7935,
+        hostapd_constants.N_CAPABILITY_SGI20,
+    ]
+    # Netgear IE
+    # WPS IE
+    # Epigram, Inc. IE
+    # Broadcom IE
+    vendor_elements = {
+        "vendor_elements": "dd0600146c000000"
+        "dd310050f204104a00011010440001021047001066189606f1e967f9c0102048817a7"
+        "69e103c0001031049000600372a000120"
+        "dd1e00904c0408bf0cb259820feaff0000eaff0000c0050001000000c3020002"
+        "dd090010180200001c0000"
+    }
+    qbss = {"bss_load_update_period": 50, "chan_util_avg_period": 600}
+
+    # 2.4GHz
+    if channel <= 11:
+        interface = iface_wlan_2g
+        rates.update(hostapd_constants.CCK_AND_OFDM_BASIC_RATES)
+        mode = hostapd_constants.MODE_11N_MIXED
+        obss_interval = 300
+        ac_capabilities = None
+
+    # 5GHz
+    else:
+        interface = iface_wlan_5g
+        rates.update(hostapd_constants.OFDM_ONLY_BASIC_RATES)
+        mode = hostapd_constants.MODE_11AC_MIXED
+        n_capabilities += [
+            hostapd_constants.N_CAPABILITY_SGI40,
+        ]
+
+        if hostapd_config.ht40_plus_allowed(channel):
+            n_capabilities.append(hostapd_constants.N_CAPABILITY_HT40_PLUS)
+        elif hostapd_config.ht40_minus_allowed(channel):
+            n_capabilities.append(hostapd_constants.N_CAPABILITY_HT40_MINUS)
+
+        obss_interval = None
+        ac_capabilities = [
+            hostapd_constants.AC_CAPABILITY_RXLDPC,
+            hostapd_constants.AC_CAPABILITY_SHORT_GI_80,
+            hostapd_constants.AC_CAPABILITY_TX_STBC_2BY1,
+            hostapd_constants.AC_CAPABILITY_RX_STBC_1,
+            hostapd_constants.AC_CAPABILITY_MAX_MPDU_11454,
+            hostapd_constants.AC_CAPABILITY_MAX_A_MPDU_LEN_EXP7,
+        ]
+
+    additional_params = (
+        rates
+        | vendor_elements
+        | qbss
+        | hostapd_constants.ENABLE_RRM_BEACON_REPORT
+        | hostapd_constants.ENABLE_RRM_NEIGHBOR_REPORT
+        | hostapd_constants.UAPSD_ENABLED
+    )
+
+    config = hostapd_config.HostapdConfig(
+        ssid=ssid,
+        channel=channel,
+        hidden=False,
+        security=security,
+        interface=interface,
+        mode=mode,
+        force_wmm=True,
+        beacon_interval=100,
+        dtim_period=2,
+        short_preamble=False,
+        obss_interval=obss_interval,
+        n_capabilities=n_capabilities,
+        ac_capabilities=ac_capabilities,
+        vht_channel_width=vht_channel_width,
+        additional_parameters=additional_params,
+    )
+    return config
+
+
+def netgear_wndr3400(
+    iface_wlan_2g: str,
+    iface_wlan_5g: str,
+    channel: int,
+    security: Security,
+    ssid: str | None = None,
+) -> hostapd_config.HostapdConfig:
+    # TODO(b/143104825): Permit RIFS on 5GHz once it is supported
+    # TODO(b/144446076): Address non-whirlwind hardware capabilities.
+    """A simulated implementation of what a Netgear WNDR3400 AP
+    Args:
+        iface_wlan_2g: The 2.4Ghz interface of the test AP.
+        iface_wlan_5g: The 5GHz interface of the test AP.
+        channel: What channel to use.
+        security: A security profile (open or WPA2).
+        ssid: The network name.
+    Returns:
+        A hostapd config.
+    Differences from real WNDR3400:
+        2.4GHz:
+            Rates:
+                WNDR3400:
+                    Supported: 1, 2, 5.5, 11, 18, 24, 36, 54
+                    Extended: 6, 9, 12, 48
+                Simulated:
+                    Supported: 1, 2, 5.5, 11, 6, 9, 12, 18
+                    Extended: 24, 36, 48,
+        5GHz:
+            HT Info:
+                WNDR3400: RIFS Permitted
+                Simulated: RIFS Prohibited
+        Both:
+            HT Capab:
+                A-MPDU
+                    WNDR3400: MPDU Density 16
+                    Simulated: MPDU Density 8
+                Info
+                    WNDR3400: Green Field supported
+                    Simulated: Green Field not supported on Whirlwind.
+    """
+    # Verify interface and security
+    hostapd_utils.verify_interface(iface_wlan_2g, hostapd_constants.INTERFACE_2G_LIST)
+    hostapd_utils.verify_interface(iface_wlan_5g, hostapd_constants.INTERFACE_5G_LIST)
+    hostapd_utils.verify_security_mode(security, [SecurityMode.OPEN, SecurityMode.WPA2])
+    if security.security_mode is not SecurityMode.OPEN:
+        hostapd_utils.verify_cipher(security, [hostapd_constants.WPA2_DEFAULT_CIPER])
+
+    # Common Parameters
+    rates = hostapd_constants.CCK_AND_OFDM_DATA_RATES
+    n_capabilities = [
+        hostapd_constants.N_CAPABILITY_SGI20,
+        hostapd_constants.N_CAPABILITY_SGI40,
+        hostapd_constants.N_CAPABILITY_TX_STBC,
+        hostapd_constants.N_CAPABILITY_MAX_AMSDU_7935,
+        hostapd_constants.N_CAPABILITY_DSSS_CCK_40,
+    ]
+    # WPS IE
+    # Broadcom IE
+    vendor_elements = {
+        "vendor_elements": "dd310050f204104a0001101044000102104700108c403eb883e7e225ab139828703ade"
+        "dc103c0001031049000600372a000120"
+        "dd090010180200f0040000"
+    }
+
+    # 2.4GHz
+    if channel <= 11:
+        interface = iface_wlan_2g
+        rates.update(hostapd_constants.CCK_AND_OFDM_BASIC_RATES)
+        obss_interval = 300
+        n_capabilities.append(hostapd_constants.N_CAPABILITY_DSSS_CCK_40)
+
+    # 5GHz
+    else:
+        interface = iface_wlan_5g
+        rates.update(hostapd_constants.OFDM_ONLY_BASIC_RATES)
+        obss_interval = None
+        n_capabilities.append(hostapd_constants.N_CAPABILITY_HT40_PLUS)
+
+    additional_params = rates | vendor_elements | hostapd_constants.UAPSD_ENABLED
+
+    config = hostapd_config.HostapdConfig(
+        ssid=ssid,
+        channel=channel,
+        hidden=False,
+        security=security,
+        interface=interface,
+        mode=hostapd_constants.MODE_11N_MIXED,
+        force_wmm=True,
+        beacon_interval=100,
+        dtim_period=2,
+        short_preamble=False,
+        obss_interval=obss_interval,
+        n_capabilities=n_capabilities,
+        additional_parameters=additional_params,
+    )
+
+    return config
diff --git a/packages/antlion/controllers/ap_lib/third_party_ap_profiles/securifi.py b/packages/antlion/controllers/ap_lib/third_party_ap_profiles/securifi.py
new file mode 100644
index 0000000..8b2d0eb
--- /dev/null
+++ b/packages/antlion/controllers/ap_lib/third_party_ap_profiles/securifi.py
@@ -0,0 +1,103 @@
+# 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.
+
+
+from antlion.controllers.ap_lib import hostapd_config, hostapd_constants, hostapd_utils
+from antlion.controllers.ap_lib.hostapd_security import Security, SecurityMode
+
+
+def securifi_almond(
+    iface_wlan_2g: str, channel: int, security: Security, ssid: str | None = None
+) -> hostapd_config.HostapdConfig:
+    """A simulated implementation of a Securifi Almond AP
+    Args:
+        iface_wlan_2g: The 2.4Ghz interface of the test AP.
+        channel: What channel to use.
+        security: A security profile (open or WPA2).
+        ssid: The network name.
+    Returns:
+        A hostapd config.
+    Differences from real Almond:
+            Rates:
+                Almond:
+                    Supported: 1, 2, 5.5, 11, 18, 24, 36, 54
+                    Extended: 6, 9, 12, 48
+                Simulated:
+                    Supported: 1, 2, 5.5, 11, 6, 9, 12, 18
+                    Extended: 24, 36, 48, 54
+            HT Capab:
+                A-MPDU
+                    Almond: MPDU Density 4
+                    Simulated: MPDU Density 8
+            RSN Capab (w/ WPA2):
+                Almond:
+                    RSN PTKSA Replay Counter Capab: 1
+                Simulated:
+                    RSN PTKSA Replay Counter Capab: 16
+    """
+    if channel > 11:
+        raise ValueError(
+            f"The Securifi Almond does not support 5Ghz. Invalid channel ({channel})"
+        )
+    # Verify interface and security
+    hostapd_utils.verify_interface(iface_wlan_2g, hostapd_constants.INTERFACE_2G_LIST)
+    hostapd_utils.verify_security_mode(security, [SecurityMode.OPEN, SecurityMode.WPA2])
+    if security.security_mode is not SecurityMode.OPEN:
+        hostapd_utils.verify_cipher(security, [hostapd_constants.WPA2_DEFAULT_CIPER])
+
+    n_capabilities = [
+        hostapd_constants.N_CAPABILITY_HT40_PLUS,
+        hostapd_constants.N_CAPABILITY_SGI20,
+        hostapd_constants.N_CAPABILITY_SGI40,
+        hostapd_constants.N_CAPABILITY_TX_STBC,
+        hostapd_constants.N_CAPABILITY_RX_STBC1,
+        hostapd_constants.N_CAPABILITY_DSSS_CCK_40,
+    ]
+
+    rates = (
+        hostapd_constants.CCK_AND_OFDM_BASIC_RATES
+        | hostapd_constants.CCK_AND_OFDM_DATA_RATES
+    )
+
+    # Ralink Technology IE
+    # Country Information IE
+    # AP Channel Report IEs
+    vendor_elements = {
+        "vendor_elements": "dd07000c4307000000"
+        "0706555320010b14"
+        "33082001020304050607"
+        "33082105060708090a0b"
+    }
+
+    qbss = {"bss_load_update_period": 50, "chan_util_avg_period": 600}
+
+    additional_params = rates | vendor_elements | qbss
+
+    config = hostapd_config.HostapdConfig(
+        ssid=ssid,
+        channel=channel,
+        hidden=False,
+        security=security,
+        interface=iface_wlan_2g,
+        mode=hostapd_constants.MODE_11N_MIXED,
+        force_wmm=True,
+        beacon_interval=100,
+        dtim_period=1,
+        short_preamble=True,
+        obss_interval=300,
+        n_capabilities=n_capabilities,
+        additional_parameters=additional_params,
+    )
+
+    return config
diff --git a/packages/antlion/controllers/ap_lib/third_party_ap_profiles/tplink.py b/packages/antlion/controllers/ap_lib/third_party_ap_profiles/tplink.py
new file mode 100644
index 0000000..1a01303
--- /dev/null
+++ b/packages/antlion/controllers/ap_lib/third_party_ap_profiles/tplink.py
@@ -0,0 +1,466 @@
+# 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.
+
+
+from antlion.controllers.ap_lib import hostapd_config, hostapd_constants, hostapd_utils
+from antlion.controllers.ap_lib.hostapd_security import Security, SecurityMode
+
+
+def tplink_archerc5(
+    iface_wlan_2g: str,
+    iface_wlan_5g: str,
+    channel: int,
+    security: Security,
+    ssid: str | None = None,
+) -> hostapd_config.HostapdConfig:
+    # TODO(b/144446076): Address non-whirlwind hardware capabilities.
+    """A simulated implementation of an TPLink ArcherC5 AP.
+    Args:
+        iface_wlan_2g: The 2.4Ghz interface of the test AP.
+        iface_wlan_5g: The 5GHz interface of the test AP.
+        channel: What channel to use.
+        security: A security profile (open or WPA2).
+        ssid: The network name.
+    Returns:
+        A hostapd config.
+    Differences from real ArcherC5:
+        2.4GHz:
+            Rates:
+                ArcherC5:
+                    Supported: 1, 2, 5.5, 11, 18, 24, 36, 54
+                    Extended: 6, 9, 12, 48
+                Simulated:
+                    Supported: 1, 2, 5.5, 11, 6, 9, 12, 18
+                    Extended: 24, 36, 48, 54
+            HT Capab:
+                Info:
+                    ArcherC5: Green Field supported
+                    Simulated: Green Field not supported on Whirlwind.
+        5GHz:
+            VHT Capab:
+                ArcherC5:
+                    SU Beamformer Supported,
+                    SU Beamformee Supported,
+                    Beamformee STS Capability: 3,
+                    Number of Sounding Dimensions: 3,
+                    VHT Link Adaptation: Both
+                Simulated:
+                    Above are not supported on Whirlwind.
+            VHT Operation Info:
+                ArcherC5: Basic MCS Map (0x0000)
+                Simulated: Basic MCS Map (0xfffc)
+            VHT Tx Power Envelope:
+                ArcherC5: Local Max Tx Pwr Constraint: 1.0 dBm
+                Simulated: Local Max Tx Pwr Constraint: 23.0 dBm
+        Both:
+            HT Capab:
+                A-MPDU
+                    ArcherC5: MPDU Density 4
+                    Simulated: MPDU Density 8
+            HT Info:
+                ArcherC5: RIFS Permitted
+                Simulated: RIFS Prohibited
+    """
+    # Verify interface and security
+    hostapd_utils.verify_interface(iface_wlan_2g, hostapd_constants.INTERFACE_2G_LIST)
+    hostapd_utils.verify_interface(iface_wlan_5g, hostapd_constants.INTERFACE_5G_LIST)
+    hostapd_utils.verify_security_mode(security, [SecurityMode.OPEN, SecurityMode.WPA2])
+    if security.security_mode is not SecurityMode.OPEN:
+        hostapd_utils.verify_cipher(security, [hostapd_constants.WPA2_DEFAULT_CIPER])
+
+    # Common Parameters
+    rates = hostapd_constants.CCK_AND_OFDM_DATA_RATES
+    vht_channel_width = 20
+    n_capabilities = [
+        hostapd_constants.N_CAPABILITY_SGI20,
+        hostapd_constants.N_CAPABILITY_TX_STBC,
+        hostapd_constants.N_CAPABILITY_RX_STBC1,
+        hostapd_constants.N_CAPABILITY_MAX_AMSDU_7935,
+    ]
+    # WPS IE
+    # Broadcom IE
+    vendor_elements = {
+        "vendor_elements": "dd310050f204104a000110104400010210470010d96c7efc2f8938f1efbd6e5148bfa8"
+        "12103c0001031049000600372a000120"
+        "dd090010180200001c0000"
+    }
+    qbss = {"bss_load_update_period": 50, "chan_util_avg_period": 600}
+
+    # 2.4GHz
+    if channel <= 11:
+        interface = iface_wlan_2g
+        rates.update(hostapd_constants.CCK_AND_OFDM_BASIC_RATES)
+        short_preamble = True
+        mode = hostapd_constants.MODE_11N_MIXED
+        n_capabilities.append(hostapd_constants.N_CAPABILITY_DSSS_CCK_40)
+        ac_capabilities = None
+
+    # 5GHz
+    else:
+        interface = iface_wlan_5g
+        rates.update(hostapd_constants.OFDM_ONLY_BASIC_RATES)
+        short_preamble = False
+        mode = hostapd_constants.MODE_11AC_MIXED
+        n_capabilities.append(hostapd_constants.N_CAPABILITY_LDPC)
+        ac_capabilities = [
+            hostapd_constants.AC_CAPABILITY_MAX_MPDU_11454,
+            hostapd_constants.AC_CAPABILITY_SHORT_GI_80,
+            hostapd_constants.AC_CAPABILITY_RXLDPC,
+            hostapd_constants.AC_CAPABILITY_TX_STBC_2BY1,
+            hostapd_constants.AC_CAPABILITY_RX_STBC_1,
+            hostapd_constants.AC_CAPABILITY_MAX_A_MPDU_LEN_EXP7,
+        ]
+
+    additional_params = (
+        rates
+        | vendor_elements
+        | qbss
+        | hostapd_constants.ENABLE_RRM_BEACON_REPORT
+        | hostapd_constants.ENABLE_RRM_NEIGHBOR_REPORT
+        | hostapd_constants.UAPSD_ENABLED
+    )
+
+    config = hostapd_config.HostapdConfig(
+        ssid=ssid,
+        channel=channel,
+        hidden=False,
+        security=security,
+        interface=interface,
+        mode=mode,
+        force_wmm=True,
+        beacon_interval=100,
+        dtim_period=1,
+        short_preamble=short_preamble,
+        n_capabilities=n_capabilities,
+        ac_capabilities=ac_capabilities,
+        vht_channel_width=vht_channel_width,
+        additional_parameters=additional_params,
+    )
+    return config
+
+
+def tplink_archerc7(
+    iface_wlan_2g: str,
+    iface_wlan_5g: str,
+    channel: int,
+    security: Security,
+    ssid: str | None = None,
+) -> hostapd_config.HostapdConfig:
+    # TODO(b/143104825): Permit RIFS once it is supported
+    """A simulated implementation of an TPLink ArcherC7 AP.
+    Args:
+        iface_wlan_2g: The 2.4Ghz interface of the test AP.
+        iface_wlan_5g: The 5GHz interface of the test AP.
+        channel: What channel to use.
+        security: A security profile (open or WPA2).
+        ssid: The network name.
+    Returns:
+        A hostapd config.
+    Differences from real ArcherC7:
+        5GHz:
+            Country Code:
+                Simulated: Has two country code IEs, one that matches
+                the actual, and another explicit IE that was required for
+                hostapd's 802.11d to work.
+        Both:
+            HT Info:
+                ArcherC7: RIFS Permitted
+                Simulated: RIFS Prohibited
+            RSN Capabilities (w/ WPA2):
+                ArcherC7:
+                    RSN PTKSA Replay Counter Capab: 1
+                Simulated:
+                    RSN PTKSA Replay Counter Capab: 16
+    """
+    # Verify interface and security
+    hostapd_utils.verify_interface(iface_wlan_2g, hostapd_constants.INTERFACE_2G_LIST)
+    hostapd_utils.verify_interface(iface_wlan_5g, hostapd_constants.INTERFACE_5G_LIST)
+    hostapd_utils.verify_security_mode(security, [SecurityMode.OPEN, SecurityMode.WPA2])
+    if security.security_mode is not SecurityMode.OPEN:
+        hostapd_utils.verify_cipher(security, [hostapd_constants.WPA2_DEFAULT_CIPER])
+
+    # Common Parameters
+    rates = hostapd_constants.CCK_AND_OFDM_DATA_RATES
+    vht_channel_width: int | None = 80
+    n_capabilities = [
+        hostapd_constants.N_CAPABILITY_LDPC,
+        hostapd_constants.N_CAPABILITY_SGI20,
+        hostapd_constants.N_CAPABILITY_TX_STBC,
+        hostapd_constants.N_CAPABILITY_RX_STBC1,
+    ]
+    # Atheros IE
+    # WPS IE
+    vendor_elements = {
+        "vendor_elements": "dd0900037f01010000ff7f"
+        "dd180050f204104a00011010440001021049000600372a000120"
+    }
+
+    # 2.4GHz
+    if channel <= 11:
+        interface = iface_wlan_2g
+        rates.update(hostapd_constants.CCK_AND_OFDM_BASIC_RATES)
+        short_preamble = True
+        mode = hostapd_constants.MODE_11N_MIXED
+        spectrum_mgmt = False
+        pwr_constraint = {}
+        ac_capabilities = None
+        vht_channel_width = None
+
+    # 5GHz
+    else:
+        interface = iface_wlan_5g
+        rates.update(hostapd_constants.OFDM_ONLY_BASIC_RATES)
+        short_preamble = False
+        mode = hostapd_constants.MODE_11AC_MIXED
+        spectrum_mgmt = True
+        # Country Information IE (w/ individual channel info)
+        vendor_elements["vendor_elements"] += (
+            "074255532024011e28011e2c011e30"
+            "011e3401173801173c01174001176401176801176c0117700117740117840117"
+            "8801178c011795011e99011e9d011ea1011ea5011e"
+        )
+        pwr_constraint = {"local_pwr_constraint": 3}
+        n_capabilities += [
+            hostapd_constants.N_CAPABILITY_SGI40,
+            hostapd_constants.N_CAPABILITY_MAX_AMSDU_7935,
+        ]
+
+        if hostapd_config.ht40_plus_allowed(channel):
+            n_capabilities.append(hostapd_constants.N_CAPABILITY_HT40_PLUS)
+        elif hostapd_config.ht40_minus_allowed(channel):
+            n_capabilities.append(hostapd_constants.N_CAPABILITY_HT40_MINUS)
+
+        ac_capabilities = [
+            hostapd_constants.AC_CAPABILITY_MAX_MPDU_11454,
+            hostapd_constants.AC_CAPABILITY_RXLDPC,
+            hostapd_constants.AC_CAPABILITY_SHORT_GI_80,
+            hostapd_constants.AC_CAPABILITY_TX_STBC_2BY1,
+            hostapd_constants.AC_CAPABILITY_RX_STBC_1,
+            hostapd_constants.AC_CAPABILITY_MAX_A_MPDU_LEN_EXP7,
+            hostapd_constants.AC_CAPABILITY_RX_ANTENNA_PATTERN,
+            hostapd_constants.AC_CAPABILITY_TX_ANTENNA_PATTERN,
+        ]
+
+    additional_params = (
+        rates | vendor_elements | hostapd_constants.UAPSD_ENABLED | pwr_constraint
+    )
+
+    config = hostapd_config.HostapdConfig(
+        ssid=ssid,
+        channel=channel,
+        hidden=False,
+        security=security,
+        interface=interface,
+        mode=mode,
+        force_wmm=True,
+        beacon_interval=100,
+        dtim_period=1,
+        short_preamble=short_preamble,
+        n_capabilities=n_capabilities,
+        ac_capabilities=ac_capabilities,
+        vht_channel_width=vht_channel_width,
+        spectrum_mgmt_required=spectrum_mgmt,
+        additional_parameters=additional_params,
+    )
+    return config
+
+
+def tplink_c1200(
+    iface_wlan_2g: str,
+    iface_wlan_5g: str,
+    channel: int,
+    security: Security,
+    ssid: str | None = None,
+) -> hostapd_config.HostapdConfig:
+    # TODO(b/143104825): Permit RIFS once it is supported
+    # TODO(b/144446076): Address non-whirlwind hardware capabilities.
+    """A simulated implementation of an TPLink C1200 AP.
+    Args:
+        iface_wlan_2g: The 2.4Ghz interface of the test AP.
+        iface_wlan_5g: The 5GHz interface of the test AP.
+        channel: What channel to use.
+        security: A security profile (open or WPA2).
+        ssid: The network name.
+    Returns:
+        A hostapd config.
+    Differences from real C1200:
+        2.4GHz:
+            Rates:
+                C1200:
+                    Supported: 1, 2, 5.5, 11, 18, 24, 36, 54
+                    Extended: 6, 9, 12, 48
+                Simulated:
+                    Supported: 1, 2, 5.5, 11, 6, 9, 12, 18
+                    Extended: 24, 36, 48, 54
+            HT Capab:
+                Info:
+                    C1200: Green Field supported
+                    Simulated: Green Field not supported on Whirlwind.
+        5GHz:
+            VHT Operation Info:
+                C1200: Basic MCS Map (0x0000)
+                Simulated: Basic MCS Map (0xfffc)
+            VHT Tx Power Envelope:
+                C1200: Local Max Tx Pwr Constraint: 7.0 dBm
+                Simulated: Local Max Tx Pwr Constraint: 23.0 dBm
+        Both:
+            HT Info:
+                C1200: RIFS Permitted
+                Simulated: RIFS Prohibited
+    """
+    # Verify interface and security
+    hostapd_utils.verify_interface(iface_wlan_2g, hostapd_constants.INTERFACE_2G_LIST)
+    hostapd_utils.verify_interface(iface_wlan_5g, hostapd_constants.INTERFACE_5G_LIST)
+    hostapd_utils.verify_security_mode(security, [SecurityMode.OPEN, SecurityMode.WPA2])
+    if security.security_mode is not SecurityMode.OPEN:
+        hostapd_utils.verify_cipher(security, [hostapd_constants.WPA2_DEFAULT_CIPER])
+
+    # Common Parameters
+    rates = hostapd_constants.CCK_AND_OFDM_DATA_RATES
+    vht_channel_width = 20
+    n_capabilities = [
+        hostapd_constants.N_CAPABILITY_SGI20,
+        hostapd_constants.N_CAPABILITY_TX_STBC,
+        hostapd_constants.N_CAPABILITY_RX_STBC1,
+        hostapd_constants.N_CAPABILITY_MAX_AMSDU_7935,
+    ]
+    # WPS IE
+    # Broadcom IE
+    vendor_elements = {
+        "vendor_elements": "dd350050f204104a000110104400010210470010000000000000000000000000000000"
+        "00103c0001031049000a00372a00012005022688"
+        "dd090010180200000c0000"
+    }
+
+    # 2.4GHz
+    if channel <= 11:
+        interface = iface_wlan_2g
+        rates.update(hostapd_constants.CCK_AND_OFDM_BASIC_RATES)
+        short_preamble = True
+        mode = hostapd_constants.MODE_11N_MIXED
+        ac_capabilities = None
+
+    # 5GHz
+    else:
+        interface = iface_wlan_5g
+        rates.update(hostapd_constants.OFDM_ONLY_BASIC_RATES)
+        short_preamble = False
+        mode = hostapd_constants.MODE_11AC_MIXED
+        n_capabilities.append(hostapd_constants.N_CAPABILITY_LDPC)
+        ac_capabilities = [
+            hostapd_constants.AC_CAPABILITY_MAX_MPDU_11454,
+            hostapd_constants.AC_CAPABILITY_SHORT_GI_80,
+            hostapd_constants.AC_CAPABILITY_RXLDPC,
+            hostapd_constants.AC_CAPABILITY_TX_STBC_2BY1,
+            hostapd_constants.AC_CAPABILITY_RX_STBC_1,
+            hostapd_constants.AC_CAPABILITY_MAX_A_MPDU_LEN_EXP7,
+        ]
+
+    additional_params = (
+        rates
+        | vendor_elements
+        | hostapd_constants.ENABLE_RRM_BEACON_REPORT
+        | hostapd_constants.ENABLE_RRM_NEIGHBOR_REPORT
+        | hostapd_constants.UAPSD_ENABLED
+    )
+
+    config = hostapd_config.HostapdConfig(
+        ssid=ssid,
+        channel=channel,
+        hidden=False,
+        security=security,
+        interface=interface,
+        mode=mode,
+        force_wmm=True,
+        beacon_interval=100,
+        dtim_period=1,
+        short_preamble=short_preamble,
+        n_capabilities=n_capabilities,
+        ac_capabilities=ac_capabilities,
+        vht_channel_width=vht_channel_width,
+        additional_parameters=additional_params,
+    )
+    return config
+
+
+def tplink_tlwr940n(
+    iface_wlan_2g: str, channel: int, security: Security, ssid: str | None = None
+) -> hostapd_config.HostapdConfig:
+    # TODO(b/143104825): Permit RIFS once it is supported
+    """A simulated implementation of an TPLink TLWR940N AP.
+    Args:
+        iface_wlan_2g: The 2.4Ghz interface of the test AP.
+        channel: What channel to use.
+        security: A security profile (open or WPA2).
+        ssid: The network name.
+    Returns:
+        A hostapd config.
+    Differences from real TLWR940N:
+        HT Info:
+            TLWR940N: RIFS Permitted
+            Simulated: RIFS Prohibited
+        RSN Capabilities (w/ WPA2):
+            TLWR940N:
+                RSN PTKSA Replay Counter Capab: 1
+            Simulated:
+                RSN PTKSA Replay Counter Capab: 16
+    """
+    if channel > 11:
+        raise ValueError(
+            "The mock TP-Link TLWR940N does not support 5Ghz. "
+            "Invalid channel (%s)" % channel
+        )
+    # Verify interface and security
+    hostapd_utils.verify_interface(iface_wlan_2g, hostapd_constants.INTERFACE_2G_LIST)
+    hostapd_utils.verify_security_mode(security, [SecurityMode.OPEN, SecurityMode.WPA2])
+    if security.security_mode is not SecurityMode.OPEN:
+        hostapd_utils.verify_cipher(security, [hostapd_constants.WPA2_DEFAULT_CIPER])
+
+    n_capabilities = [
+        hostapd_constants.N_CAPABILITY_SGI20,
+        hostapd_constants.N_CAPABILITY_TX_STBC,
+        hostapd_constants.N_CAPABILITY_RX_STBC1,
+    ]
+
+    rates = (
+        hostapd_constants.CCK_AND_OFDM_BASIC_RATES
+        | hostapd_constants.CCK_AND_OFDM_DATA_RATES
+    )
+
+    # Atheros Communications, Inc. IE
+    # WPS IE
+    vendor_elements = {
+        "vendor_elements": "dd0900037f01010000ff7f"
+        "dd260050f204104a0001101044000102104900140024e2600200010160000002000160"
+        "0100020001"
+    }
+
+    additional_params = rates | vendor_elements | hostapd_constants.UAPSD_ENABLED
+
+    config = hostapd_config.HostapdConfig(
+        ssid=ssid,
+        channel=channel,
+        hidden=False,
+        security=security,
+        interface=iface_wlan_2g,
+        mode=hostapd_constants.MODE_11N_MIXED,
+        force_wmm=True,
+        beacon_interval=100,
+        dtim_period=1,
+        short_preamble=True,
+        n_capabilities=n_capabilities,
+        additional_parameters=additional_params,
+    )
+
+    return config
diff --git a/packages/antlion/controllers/ap_lib/wireless_network_management.py b/packages/antlion/controllers/ap_lib/wireless_network_management.py
new file mode 100644
index 0000000..848cf5f
--- /dev/null
+++ b/packages/antlion/controllers/ap_lib/wireless_network_management.py
@@ -0,0 +1,151 @@
+#!/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.
+
+from typing import NewType
+
+from antlion.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: BssTerminationDuration | None = None,
+        session_information_url: str | None = None,
+        candidate_list: BssTransitionCandidateList | None = 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) -> int | None:
+        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) -> BssTerminationDuration | None:
+        return self._bss_termination_duration
+
+    @property
+    def session_information_url(self) -> str | None:
+        return self._session_information_url
+
+    @property
+    def candidate_list(self) -> BssTransitionCandidateList | None:
+        return self._candidate_list
diff --git a/packages/antlion/controllers/attenuator.py b/packages/antlion/controllers/attenuator.py
new file mode 100644
index 0000000..f9c8b97
--- /dev/null
+++ b/packages/antlion/controllers/attenuator.py
@@ -0,0 +1,364 @@
+#!/usr/bin/env python3.4
+#
+# 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.
+
+from __future__ import annotations
+
+import enum
+import logging
+from typing import Protocol, runtime_checkable
+
+from antlion.libs.proc import job
+from antlion.types import ControllerConfig, Json
+from antlion.validation import MapValidator
+
+MOBLY_CONTROLLER_CONFIG_NAME: str = "Attenuator"
+ACTS_CONTROLLER_REFERENCE_NAME = "attenuators"
+_ATTENUATOR_OPEN_RETRIES = 3
+
+
+class Model(enum.StrEnum):
+    AEROFLEX_TELNET = "aeroflex.telnet"
+    MINICIRCUITS_HTTP = "minicircuits.http"
+    MINICIRCUITS_TELNET = "minicircuits.telnet"
+
+    def create(self, instrument_count: int) -> AttenuatorInstrument:
+        match self:
+            case Model.AEROFLEX_TELNET:
+                import antlion.controllers.attenuator_lib.aeroflex.telnet
+
+                return antlion.controllers.attenuator_lib.aeroflex.telnet.AttenuatorInstrument(
+                    instrument_count
+                )
+            case Model.MINICIRCUITS_HTTP:
+                import antlion.controllers.attenuator_lib.minicircuits.http
+
+                return antlion.controllers.attenuator_lib.minicircuits.http.AttenuatorInstrument(
+                    instrument_count
+                )
+            case Model.MINICIRCUITS_TELNET:
+                import antlion.controllers.attenuator_lib.minicircuits.telnet
+
+                return antlion.controllers.attenuator_lib.minicircuits.telnet.AttenuatorInstrument(
+                    instrument_count
+                )
+
+
+def create(configs: list[ControllerConfig]) -> list[Attenuator]:
+    attenuators: list[Attenuator] = []
+    for config in configs:
+        c = MapValidator(config)
+        attn_model = c.get(str, "Model")
+        protocol = c.get(str, "Protocol", "telnet")
+        model = Model(f"{attn_model}.{protocol}")
+
+        instrument_count = c.get(int, "InstrumentCount")
+        attenuator_instrument = model.create(instrument_count)
+
+        address = c.get(str, "Address")
+        port = c.get(int, "Port")
+
+        for attempt_number in range(1, _ATTENUATOR_OPEN_RETRIES + 1):
+            try:
+                attenuator_instrument.open(address, port)
+            except Exception as e:
+                logging.error(
+                    "Attempt %s to open connection to attenuator " "failed: %s",
+                    attempt_number,
+                    e,
+                )
+                if attempt_number == _ATTENUATOR_OPEN_RETRIES:
+                    ping_output = job.run(
+                        f"ping {address} -c 1 -w 1", ignore_status=True
+                    )
+                    if ping_output.returncode == 1:
+                        logging.error("Unable to ping attenuator at %s", address)
+                    else:
+                        logging.error("Able to ping attenuator at %s", address)
+                        job.run(
+                            ["telnet", address, str(port)],
+                            stdin=b"q",
+                            ignore_status=True,
+                        )
+                    raise
+        for i in range(instrument_count):
+            attenuators.append(Attenuator(attenuator_instrument, idx=i))
+    return attenuators
+
+
+def destroy(objects: list[Attenuator]) -> None:
+    for attn in objects:
+        attn.instrument.close()
+
+
+def get_info(objects: list[Attenuator]) -> list[Json]:
+    """Get information on a list of Attenuator objects.
+
+    Args:
+        attenuators: A list of Attenuator objects.
+
+    Returns:
+        A list of dict, each representing info for Attenuator objects.
+    """
+    return [
+        {
+            "Address": attenuator.instrument.address,
+            "Attenuator_Port": attenuator.idx,
+        }
+        for attenuator in objects
+    ]
+
+
+def get_attenuators_for_device(
+    device_attenuator_configs: list[ControllerConfig],
+    attenuators: list[Attenuator],
+    attenuator_key: str,
+) -> list[Attenuator]:
+    """Gets the list of attenuators associated to a specified device and builds
+    a list of the attenuator objects associated to the ip address in the
+    device's section of the ACTS config and the Attenuator's IP address.  In the
+    example below the access point object has an attenuator dictionary with
+    IP address associated to an attenuator object.  The address is the only
+    mandatory field and the 'attenuator_ports_wifi_2g' and
+    'attenuator_ports_wifi_5g' are the attenuator_key specified above.  These
+    can be anything and is sent in as a parameter to this function.  The numbers
+    in the list are ports that are in the attenuator object.  Below is an
+    standard Access_Point object and the link to a standard Attenuator object.
+    Notice the link is the IP address, which is why the IP address is mandatory.
+
+    "AccessPoint": [
+        {
+          "ssh_config": {
+            "user": "root",
+            "host": "192.168.42.210"
+          },
+          "Attenuator": [
+            {
+              "Address": "192.168.42.200",
+              "attenuator_ports_wifi_2g": [
+                0,
+                1,
+                3
+              ],
+              "attenuator_ports_wifi_5g": [
+                0,
+                1
+              ]
+            }
+          ]
+        }
+      ],
+      "Attenuator": [
+        {
+          "Model": "minicircuits",
+          "InstrumentCount": 4,
+          "Address": "192.168.42.200",
+          "Port": 23
+        }
+      ]
+    Args:
+        device_attenuator_configs: A list of attenuators config information in
+            the acts config that are associated a particular device.
+        attenuators: A list of all of the available attenuators objects
+            in the testbed.
+        attenuator_key: A string that is the key to search in the device's
+            configuration.
+
+    Returns:
+        A list of attenuator objects for the specified device and the key in
+        that device's config.
+    """
+    attenuator_list = []
+    for device_attenuator_config in device_attenuator_configs:
+        c = MapValidator(device_attenuator_config)
+        ports = c.list(attenuator_key).all(int)
+        for port in ports:
+            for attenuator in attenuators:
+                if (
+                    attenuator.instrument.address == device_attenuator_config["Address"]
+                    and attenuator.idx is port
+                ):
+                    attenuator_list.append(attenuator)
+    return attenuator_list
+
+
+#
+# Classes for accessing, managing, and manipulating attenuators.
+#
+# Users will instantiate a specific child class, but almost all operation should
+# be performed on the methods and data members defined here in the base classes
+# or the wrapper classes.
+#
+
+
+class AttenuatorError(Exception):
+    """Base class for all errors generated by Attenuator-related modules."""
+
+
+class InvalidDataError(AttenuatorError):
+    """ "Raised when an unexpected result is seen on the transport layer.
+
+    When this exception is seen, closing an re-opening the link to the
+    attenuator instrument is probably necessary. Something has gone wrong in
+    the transport.
+    """
+
+
+class InvalidOperationError(AttenuatorError):
+    """Raised when the attenuator's state does not allow the given operation.
+
+    Certain methods may only be accessed when the instance upon which they are
+    invoked is in a certain state. This indicates that the object is not in the
+    correct state for a method to be called.
+    """
+
+
+INVALID_MAX_ATTEN: float = 999.9
+
+
+@runtime_checkable
+class AttenuatorInstrument(Protocol):
+    """Defines the primitive behavior of all attenuator instruments.
+
+    The AttenuatorInstrument class is designed to provide a simple low-level
+    interface for accessing any step attenuator instrument comprised of one or
+    more attenuators and a controller. All AttenuatorInstruments should override
+    all the methods below and call AttenuatorInstrument.__init__ in their
+    constructors. Outside of setup/teardown, devices should be accessed via
+    this generic "interface".
+    """
+
+    @property
+    def address(self) -> str | None:
+        """Return the address to the attenuator."""
+        ...
+
+    @property
+    def num_atten(self) -> int:
+        """Return the index used to identify this attenuator in an instrument."""
+        ...
+
+    @property
+    def max_atten(self) -> float:
+        """Return the maximum allowed attenuation value."""
+        ...
+
+    def open(self, host: str, port: int, timeout_sec: int = 5) -> None:
+        """Initiate a connection to the attenuator.
+
+        Args:
+            host: A valid hostname to an attenuator
+            port: Port number to attempt connection
+            timeout_sec: Seconds to wait to initiate a connection
+        """
+        ...
+
+    def close(self) -> None:
+        """Close the connection to the attenuator."""
+        ...
+
+    def set_atten(
+        self, idx: int, value: float, strict: bool = True, retry: bool = False
+    ) -> None:
+        """Sets the attenuation given its index in the instrument.
+
+        Args:
+            idx: Index used to identify a particular attenuator in an instrument
+            value: Value for nominal attenuation to be set
+            strict: If True, raise an error when given out of bounds attenuation
+            retry: If True, command will be retried if possible
+        """
+        ...
+
+    def get_atten(self, idx: int, retry: bool = False) -> float:
+        """Returns the current attenuation given its index in the instrument.
+
+        Args:
+            idx: Index used to identify a particular attenuator in an instrument
+            retry: If True, command will be retried if possible
+
+        Returns:
+            The current attenuation value
+        """
+        ...
+
+
+class Attenuator(object):
+    """An object representing a single attenuator in a remote instrument.
+
+    A user wishing to abstract the mapping of attenuators to physical
+    instruments should use this class, which provides an object that abstracts
+    the physical implementation and allows the user to think only of attenuators
+    regardless of their location.
+    """
+
+    def __init__(
+        self, instrument: AttenuatorInstrument, idx: int = 0, offset: int = 0
+    ) -> None:
+        """This is the constructor for Attenuator
+
+        Args:
+            instrument: Reference to an AttenuatorInstrument on which the
+                Attenuator resides
+            idx: This zero-based index is the identifier for a particular
+                attenuator in an instrument.
+            offset: A power offset value for the attenuator to be used when
+                performing future operations. This could be used for either
+                calibration or to allow group operations with offsets between
+                various attenuators.
+
+        Raises:
+            TypeError if an invalid AttenuatorInstrument is passed in.
+            IndexError if the index is out of range.
+        """
+        if not isinstance(instrument, AttenuatorInstrument):
+            raise TypeError("Must provide an Attenuator Instrument Ref")
+        self.instrument = instrument
+        self.idx = idx
+        self.offset = offset
+
+        if self.idx >= instrument.num_atten:
+            raise IndexError("Attenuator index out of range for attenuator instrument")
+
+    def set_atten(self, value: float, strict: bool = True, retry: bool = False) -> None:
+        """Sets the attenuation.
+
+        Args:
+            value: A floating point value for nominal attenuation to be set.
+            strict: if True, function raises an error when given out of
+                bounds attenuation values, if false, the function sets out of
+                bounds values to 0 or max_atten.
+            retry: if True, command will be retried if possible
+
+        Raises:
+            ValueError if value + offset is greater than the maximum value.
+        """
+        if value + self.offset > self.instrument.max_atten and strict:
+            raise ValueError("Attenuator Value+Offset greater than Max Attenuation!")
+
+        self.instrument.set_atten(
+            self.idx, value + self.offset, strict=strict, retry=retry
+        )
+
+    def get_atten(self, retry: bool = False) -> float:
+        """Returns the attenuation as a float, normalized by the offset."""
+        return self.instrument.get_atten(self.idx, retry) - self.offset
+
+    def get_max_atten(self) -> float:
+        """Returns the max attenuation as a float, normalized by the offset."""
+        if self.instrument.max_atten == INVALID_MAX_ATTEN:
+            raise ValueError("Invalid Max Attenuator Value")
+
+        return self.instrument.max_atten - self.offset
diff --git a/src/antlion/controllers/attenuator_lib/__init__.py b/packages/antlion/controllers/attenuator_lib/__init__.py
similarity index 100%
rename from src/antlion/controllers/attenuator_lib/__init__.py
rename to packages/antlion/controllers/attenuator_lib/__init__.py
diff --git a/packages/antlion/controllers/attenuator_lib/_tnhelper.py b/packages/antlion/controllers/attenuator_lib/_tnhelper.py
new file mode 100644
index 0000000..8ea8289
--- /dev/null
+++ b/packages/antlion/controllers/attenuator_lib/_tnhelper.py
@@ -0,0 +1,140 @@
+#!/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.
+"""A helper module to communicate over telnet with AttenuatorInstruments.
+
+User code shouldn't need to directly access this class.
+"""
+
+import logging
+import re
+import telnetlib
+
+from antlion.controllers import attenuator
+from antlion.libs.proc import job
+
+
+def _ascii_string(uc_string):
+    return str(uc_string).encode("ASCII")
+
+
+class TelnetHelper(object):
+    """An internal helper class for Telnet+SCPI command-based instruments.
+
+    It should only be used by those implementation control libraries and not by
+    any user code directly.
+    """
+
+    def __init__(
+        self,
+        tx_cmd_separator: str = "\n",
+        rx_cmd_separator: str = "\n",
+        prompt: str = "",
+    ) -> None:
+        self._tn: telnetlib.Telnet | None = None
+        self._ip_address: str | None = None
+        self._port: int | None = None
+
+        self.tx_cmd_separator = tx_cmd_separator
+        self.rx_cmd_separator = rx_cmd_separator
+        self.prompt = prompt
+
+    def open(self, host: str, port: int = 23) -> None:
+        self._ip_address = host
+        self._port = port
+        if self._tn:
+            self._tn.close()
+        logging.debug("Telnet Server IP = %s", host)
+        self._tn = telnetlib.Telnet(host, port, timeout=10)
+
+    def is_open(self) -> bool:
+        return self._tn is not None
+
+    def close(self) -> None:
+        if self._tn:
+            self._tn.close()
+            self._tn = None
+
+    def diagnose_telnet(self, host: str, port: int) -> bool:
+        """Function that diagnoses telnet connections.
+
+        This function diagnoses telnet connections and can be used in case of
+        command failures. The function checks if the devices is still reachable
+        via ping, and whether or not it can close and reopen the telnet
+        connection.
+
+        Returns:
+            False when telnet server is unreachable or unresponsive
+            True when telnet server is reachable and telnet connection has been
+            successfully reopened
+        """
+        logging.debug("Diagnosing telnet connection")
+        try:
+            job_result = job.run(f"ping {host} -c 5 -i 0.2")
+        except Exception as e:
+            logging.error("Unable to ping telnet server: %s", e)
+            return False
+        ping_output = job_result.stdout.decode("utf-8")
+        if not re.search(r" 0% packet loss", ping_output):
+            logging.error("Ping Packets Lost. Result: %s", ping_output)
+            return False
+        try:
+            self.close()
+        except Exception as e:
+            logging.error("Cannot close telnet connection: %s", e)
+            return False
+        try:
+            self.open(host, port)
+        except Exception as e:
+            logging.error("Cannot reopen telnet connection: %s", e)
+            return False
+        logging.debug("Telnet connection likely recovered")
+        return True
+
+    def cmd(self, cmd_str: str, retry: bool = False) -> str:
+        if not isinstance(cmd_str, str):
+            raise TypeError("Invalid command string", cmd_str)
+
+        if self._tn is None or self._ip_address is None or self._port is None:
+            raise attenuator.InvalidOperationError(
+                "Telnet connection not open for commands"
+            )
+
+        cmd_str.strip(self.tx_cmd_separator)
+        self._tn.read_until(_ascii_string(self.prompt), 2)
+        self._tn.write(_ascii_string(cmd_str + self.tx_cmd_separator))
+
+        match_idx, match_val, ret_text = self._tn.expect(
+            [_ascii_string(f"\\S+{self.rx_cmd_separator}")], 1
+        )
+
+        logging.debug("Telnet Command: %s", cmd_str)
+        logging.debug("Telnet Reply: (%s, %s, %s)", match_idx, match_val, ret_text)
+
+        if match_idx == -1:
+            telnet_recovered = self.diagnose_telnet(self._ip_address, self._port)
+            if telnet_recovered and retry:
+                logging.debug("Retrying telnet command once.")
+                return self.cmd(cmd_str, retry=False)
+            else:
+                raise attenuator.InvalidDataError(
+                    "Telnet command failed to return valid data"
+                )
+
+        ret_str = ret_text.decode()
+        ret_str = ret_str.strip(
+            self.tx_cmd_separator + self.rx_cmd_separator + self.prompt
+        )
+        return ret_str
diff --git a/src/antlion/controllers/attenuator_lib/aeroflex/__init__.py b/packages/antlion/controllers/attenuator_lib/aeroflex/__init__.py
similarity index 100%
rename from src/antlion/controllers/attenuator_lib/aeroflex/__init__.py
rename to packages/antlion/controllers/attenuator_lib/aeroflex/__init__.py
diff --git a/packages/antlion/controllers/attenuator_lib/aeroflex/telnet.py b/packages/antlion/controllers/attenuator_lib/aeroflex/telnet.py
new file mode 100644
index 0000000..f4544f3
--- /dev/null
+++ b/packages/antlion/controllers/attenuator_lib/aeroflex/telnet.py
@@ -0,0 +1,136 @@
+#!/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.
+"""
+Class for Telnet control of Aeroflex 832X and 833X Series Attenuator Modules
+
+This class provides a wrapper to the Aeroflex attenuator modules for purposes
+of simplifying and abstracting control down to the basic necessities. It is
+not the intention of the module to expose all functionality, but to allow
+interchangeable HW to be used.
+
+See http://www.aeroflex.com/ams/weinschel/PDFILES/IM-608-Models-8320-&-8321-preliminary.pdf
+"""
+
+from antlion.controllers import attenuator
+from antlion.controllers.attenuator_lib import _tnhelper
+
+
+class AttenuatorInstrument(attenuator.AttenuatorInstrument):
+    def __init__(self, num_atten: int = 0) -> None:
+        self._num_atten = num_atten
+        self._max_atten = attenuator.INVALID_MAX_ATTEN
+
+        self._tnhelper = _tnhelper.TelnetHelper(
+            tx_cmd_separator="\r\n", rx_cmd_separator="\r\n", prompt=">"
+        )
+        self._properties: dict[str, str] | None = None
+        self._address: str | None = None
+
+    @property
+    def address(self) -> str | None:
+        return self._address
+
+    @property
+    def num_atten(self) -> int:
+        return self._num_atten
+
+    @property
+    def max_atten(self) -> float:
+        return self._max_atten
+
+    def open(self, host: str, port: int, _timeout_sec: int = 5) -> None:
+        """Initiate a connection to the attenuator.
+
+        Args:
+            host: A valid hostname to an attenuator
+            port: Port number to attempt connection
+            timeout_sec: Seconds to wait to initiate a connection
+        """
+        self._tnhelper.open(host, port)
+
+        # work around a bug in IO, but this is a good thing to do anyway
+        self._tnhelper.cmd("*CLS", False)
+        self._address = host
+
+        if self._num_atten == 0:
+            self._num_atten = int(self._tnhelper.cmd("RFCONFIG? CHAN"))
+
+        configstr = self._tnhelper.cmd("RFCONFIG? ATTN 1")
+
+        self._properties = dict(
+            zip(
+                ["model", "max_atten", "min_step", "unknown", "unknown2", "cfg_str"],
+                configstr.split(", ", 5),
+            )
+        )
+
+        self._max_atten = float(self._properties["max_atten"])
+
+    def close(self) -> None:
+        """Close the connection to the attenuator."""
+        self._tnhelper.close()
+
+    def set_atten(
+        self, idx: int, value: float, _strict: bool = True, _retry: bool = False
+    ) -> None:
+        """Sets the attenuation given its index in the instrument.
+
+        Args:
+            idx: Index used to identify a particular attenuator in an instrument
+            value: Value for nominal attenuation to be set
+            strict: If True, raise an error when given out of bounds attenuation
+            retry: If True, command will be retried if possible
+
+        Raises:
+            InvalidOperationError if the telnet connection is not open.
+            IndexError if the index is not valid for this instrument.
+            ValueError if the requested set value is greater than the maximum
+                attenuation value.
+        """
+        if not self._tnhelper.is_open():
+            raise attenuator.InvalidOperationError("Connection not open!")
+
+        if idx >= self._num_atten:
+            raise IndexError("Attenuator index out of range!", self._num_atten, idx)
+
+        if value > self._max_atten:
+            raise ValueError("Attenuator value out of range!", self._max_atten, value)
+
+        self._tnhelper.cmd(f"ATTN {idx + 1} {value}", False)
+
+    def get_atten(self, idx: int, _retry: bool = False) -> float:
+        """Returns the current attenuation given its index in the instrument.
+
+        Args:
+            idx: Index used to identify a particular attenuator in an instrument
+            retry: If True, command will be retried if possible
+
+        Raises:
+            InvalidOperationError if the telnet connection is not open.
+
+        Returns:
+            The current attenuation value
+        """
+        if not self._tnhelper.is_open():
+            raise attenuator.InvalidOperationError("Connection not open!")
+
+        #       Potentially redundant safety check removed for the moment
+        #       if idx >= self.num_atten:
+        #           raise IndexError("Attenuator index out of range!", self.num_atten, idx)
+
+        atten_val = self._tnhelper.cmd(f"ATTN? {idx + 1}")
+
+        return float(atten_val)
diff --git a/src/antlion/controllers/attenuator_lib/minicircuits/__init__.py b/packages/antlion/controllers/attenuator_lib/minicircuits/__init__.py
similarity index 100%
rename from src/antlion/controllers/attenuator_lib/minicircuits/__init__.py
rename to packages/antlion/controllers/attenuator_lib/minicircuits/__init__.py
diff --git a/packages/antlion/controllers/attenuator_lib/minicircuits/http.py b/packages/antlion/controllers/attenuator_lib/minicircuits/http.py
new file mode 100644
index 0000000..98118ad
--- /dev/null
+++ b/packages/antlion/controllers/attenuator_lib/minicircuits/http.py
@@ -0,0 +1,158 @@
+#!/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.
+"""
+Class for HTTP control of Mini-Circuits RCDAT series attenuators
+
+This class provides a wrapper to the MC-RCDAT attenuator modules for purposes
+of simplifying and abstracting control down to the basic necessities. It is
+not the intention of the module to expose all functionality, but to allow
+interchangeable HW to be used.
+
+See http://www.minicircuits.com/softwaredownload/Prog_Manual-6-Programmable_Attenuator.pdf
+"""
+
+import urllib.request
+
+from antlion.controllers import attenuator
+
+
+class AttenuatorInstrument(attenuator.AttenuatorInstrument):
+    """A specific HTTP-controlled implementation of AttenuatorInstrument for
+    Mini-Circuits RC-DAT attenuators.
+
+    With the exception of HTTP-specific commands, all functionality is defined
+    by the AttenuatorInstrument class.
+    """
+
+    def __init__(self, num_atten: int = 1) -> None:
+        self._num_atten = num_atten
+        self._max_atten = attenuator.INVALID_MAX_ATTEN
+
+        self._ip_address: str | None = None
+        self._port: int | None = None
+        self._timeout: int | None = None
+        self._address: str | None = None
+
+    @property
+    def address(self) -> str | None:
+        return self._address
+
+    @property
+    def num_atten(self) -> int:
+        return self._num_atten
+
+    @property
+    def max_atten(self) -> float:
+        return self._max_atten
+
+    def open(self, host: str, port: int = 80, timeout_sec: int = 2) -> None:
+        """Initiate a connection to the attenuator.
+
+        Args:
+            host: A valid hostname to an attenuator
+            port: Port number to attempt connection
+            timeout_sec: Seconds to wait to initiate a connection
+        """
+        self._ip_address = host
+        self._port = port
+        self._timeout = timeout_sec
+        self._address = host
+
+        att_req = urllib.request.urlopen(f"http://{self._ip_address}:{self._port}/MN?")
+        config_str = att_req.read().decode("utf-8").strip()
+        if not config_str.startswith("MN="):
+            raise attenuator.InvalidDataError(
+                f"Attenuator returned invalid data. Attenuator returned: {config_str}"
+            )
+
+        config_str = config_str[len("MN=") :]
+        properties = dict(
+            zip(["model", "max_freq", "max_atten"], config_str.split("-", 2))
+        )
+        self._max_atten = float(properties["max_atten"])
+
+    def close(self) -> None:
+        """Close the connection to the attenuator."""
+        # Since this controller is based on HTTP requests, there is no
+        # connection teardown required.
+
+    def set_atten(
+        self, idx: int, value: float, strict: bool = True, retry: bool = False
+    ) -> None:
+        """Sets the attenuation given its index in the instrument.
+
+        Args:
+            idx: Index used to identify a particular attenuator in an instrument
+            value: Value for nominal attenuation to be set
+            strict: If True, raise an error when given out of bounds attenuation
+            retry: If True, command will be retried if possible
+
+        Raises:
+            InvalidDataError if the attenuator does not respond with the
+            expected output.
+        """
+        if not (0 <= idx < self._num_atten):
+            raise IndexError("Attenuator index out of range!", self._num_atten, idx)
+
+        if value > self._max_atten and strict:
+            raise ValueError("Attenuator value out of range!", self._max_atten, value)
+        # The actual device uses one-based index for channel numbers.
+        adjusted_value = min(max(0, value), self._max_atten)
+        att_req = urllib.request.urlopen(
+            "http://{}:{}/CHAN:{}:SETATT:{}".format(
+                self._ip_address, self._port, idx + 1, adjusted_value
+            ),
+            timeout=self._t