Add CNET-4.28 and CNET-4.30 scripts, support PDC and emulated WiFi in Linux all-devices-app (#74932)

* all-devices-app: Add EmulatedWiFiDriver

An in-memory WiFiDriver (with PDC support) that never touches a real
interface, so the Network Commissioning cluster can be exercised on a host
that is already on the network, e.g. as the TH server in TC_CNET_4_30. Not
wired up yet.

* all-devices-app: Add --wifi-emulated option

Uses EmulatedWiFiDriver for the Wi-Fi Network Commissioning cluster on
endpoint 0, and doesn't start wpa_supplicant management. Not offered on
Darwin, where CHIP_DEVICE_CONFIG_ENABLE_WIFI_STATION is 0 and the cluster
would reject all commands that add a network.

* all-devices-app: Enable Wi-Fi PDC in posix builds

Note that this also makes --wifi report the PDC feature on Linux, because
LinuxWiFiDriver always claims support for it.

* Python testing: Fix wifi-fixture shutdown / restart race

The code that was waiting for the fixture daemon to restart was simply
polling for the absence and then presence of the control socket, but the
daemon can restart quickly enough so that the intermediate absence of it
is missed. Check for the file identity (inode number) of the socket
instead.

* TC_CNET_4_28: Network Client Identity reuse across network configs

* TC_CNET_4_30: Verification for PDC commands (DUT client)

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Address review comments

Mark SsidForLog [[maybe_unused]], since it is only used for logging and
breaks the chip_logging=false build otherwise.

Also make TC_CNET_4_28 tolerate the DUT dropping off the network when the
fail-safe revert restores a removed network, the same way TC_CNET_4_29 does.

* Mark TC_CNET_4_28 and TC_CNET_4_30 as not automated

Neither has a CI configuration yet, so CI should skip them rather than fail
on the missing arguments.

* Python testing: Keep the old wifi-fixture socket open during restart

A deleted socket's inode can be reused for the new daemon's socket, which
made the restart look like it never happened. Holding the old socket open
with O_PATH prevents that.

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
diff --git a/examples/BUILD.gn b/examples/BUILD.gn
index 5a6a52b..3e6d8ec 100644
--- a/examples/BUILD.gn
+++ b/examples/BUILD.gn
@@ -34,6 +34,7 @@
     }
     tests += [
       "${chip_root}/examples/all-devices-app/posix/app_options/tests",
+      "${chip_root}/examples/all-devices-app/posix/emulated_network/tests",
       "${chip_root}/examples/all-devices-app/posix/named_pipe/tests",
       "${chip_root}/examples/all-devices-app/all-devices-common/device/api/allocator/tests",
       "${chip_root}/examples/all-devices-app/all-devices-common/device/types/air-quality-sensor/tests",
diff --git a/examples/all-devices-app/docs/testing.md b/examples/all-devices-app/docs/testing.md
index b56fdb0..3abb592 100644
--- a/examples/all-devices-app/docs/testing.md
+++ b/examples/all-devices-app/docs/testing.md
@@ -128,3 +128,15 @@
     --passcode 20202021 \
     --endpoint 1
 ```
+
+## 3. Emulated Wi-Fi (`--wifi-emulated`)
+
+On Linux, `--wifi-emulated` adds a Wi-Fi Network Commissioning cluster to
+endpoint 0 that is backed by an in-memory driver instead of wpa_supplicant. The
+driver accepts network configurations and reports ConnectNetwork as successful
+for any configured network, but never touches a real interface. This allows the
+cluster to be exercised on a host that is already on the network, e.g. a test
+harness machine. The app stays reachable over IP throughout.
+
+Per-Device Credentials are supported. The driver keeps no state across restarts,
+and supports a single network configuration (MaxNetworks is 1).
diff --git a/examples/all-devices-app/posix/DeviceInstances.cpp b/examples/all-devices-app/posix/DeviceInstances.cpp
index 49eddd9..8c6614e 100644
--- a/examples/all-devices-app/posix/DeviceInstances.cpp
+++ b/examples/all-devices-app/posix/DeviceInstances.cpp
@@ -76,13 +76,14 @@
             .termsAndConditionsProvider = mContext.termsAndConditionsProvider,
 #endif // CHIP_CONFIG_TERMS_AND_CONDITIONS_REQUIRED
         },
-        []([[maybe_unused]] bool enableWiFi) {
+        []([[maybe_unused]] bool enableWiFi, [[maybe_unused]] bool emulateWiFi) {
             BitFlags<AppRootNode::EnabledFeatures> features;
 #if CHIP_DEVICE_CONFIG_ENABLE_WIFI
             features.Set(AppRootNode::EnabledFeatures::kWiFi, enableWiFi);
+            features.Set(AppRootNode::EnabledFeatures::kEmulatedWiFi, emulateWiFi);
 #endif
             return features;
-        }(mContext.enableWiFi))
+        }(mContext.enableWiFi, mContext.emulateWiFi))
 {}
 
 CHIP_ERROR DeviceInstances::Startup(const std::vector<DeviceTypeParser::Entry> & deviceEntries)
diff --git a/examples/all-devices-app/posix/DeviceInstances.h b/examples/all-devices-app/posix/DeviceInstances.h
index 0ee7c8e..d9c6ba3 100644
--- a/examples/all-devices-app/posix/DeviceInstances.h
+++ b/examples/all-devices-app/posix/DeviceInstances.h
@@ -101,7 +101,8 @@
         EventManagement & eventManagement;
         TimerDelegate & timerDelegate;
         uint16_t minGuaranteedSubscriptionsPerFabric;
-        bool enableWiFi = false;
+        bool enableWiFi  = false;
+        bool emulateWiFi = false;
 #if CHIP_CONFIG_TERMS_AND_CONDITIONS_REQUIRED
         TermsAndConditionsProvider & termsAndConditionsProvider;
 #endif // CHIP_CONFIG_TERMS_AND_CONDITIONS_REQUIRED
diff --git a/examples/all-devices-app/posix/app_options/AppOptions.cpp b/examples/all-devices-app/posix/app_options/AppOptions.cpp
index bac901f..d1dca34 100644
--- a/examples/all-devices-app/posix/app_options/AppOptions.cpp
+++ b/examples/all-devices-app/posix/app_options/AppOptions.cpp
@@ -67,6 +67,7 @@
 #endif
 constexpr uint16_t kOptionRpcServerPort = 0xffe0;
 constexpr uint16_t kOptionPasscode      = 0xffe1;
+constexpr uint16_t kOptionWiFiEmulated  = 0xffe2;
 
 DeviceTypeParser AppOptions::sParser;
 AppOptions::AppConfig AppOptions::mConfig;
@@ -200,6 +201,11 @@
         mConfig.enableWiFi = true;
         ChipLogProgress(AppServer, "WiFi usage enabled");
         return true;
+    case kOptionWiFiEmulated:
+        mConfig.enableWiFi  = true;
+        mConfig.emulateWiFi = true;
+        ChipLogProgress(AppServer, "WiFi usage enabled with an emulated driver");
+        return true;
     case kOptionKVS:
         mConfig.kvsPath = value;
         return true;
@@ -317,6 +323,9 @@
 #endif
 #if CHIP_DEVICE_CONFIG_ENABLE_WIFI
         { "wifi", kNoArgument, kOptionWiFi },
+#if CHIP_DEVICE_CONFIG_ENABLE_WIFI_STATION
+        { "wifi-emulated", kNoArgument, kOptionWiFiEmulated },
+#endif
 #endif
         { "KVS", kArgumentRequired, kOptionKVS },
         { "discriminator", kArgumentRequired, kOptionDiscriminator },
@@ -364,6 +373,11 @@
 #if CHIP_DEVICE_CONFIG_ENABLE_WIFI
         result += "  --wifi\n";
         result += "       Enable wifi support for commissioning\n\n";
+#if CHIP_DEVICE_CONFIG_ENABLE_WIFI_STATION
+        result += "  --wifi-emulated\n";
+        result += "       Enable wifi support for commissioning, using an emulated driver that does not\n";
+        result += "       touch any real network interface. ConnectNetwork succeeds for any configured network.\n\n";
+#endif
 #endif
 
         result += "  --KVS <path>\n";
diff --git a/examples/all-devices-app/posix/app_options/AppOptions.h b/examples/all-devices-app/posix/app_options/AppOptions.h
index a6ba999..894bc60 100644
--- a/examples/all-devices-app/posix/app_options/AppOptions.h
+++ b/examples/all-devices-app/posix/app_options/AppOptions.h
@@ -52,6 +52,7 @@
         std::optional<std::string> dacProvider;
         uint8_t testEventTriggerEnableKey[16] = { 0 };
         bool enableWiFi                       = false;
+        bool emulateWiFi                      = false; // use EmulatedWiFiDriver; implies enableWiFi
         uint32_t bleController                = 0;
         /// Listen port for the Pigweed RPC server, unset when --rpc-server-port was not given.
         /// This is unconditionally parsed, but only consumed by builds compiled with Pigweed RPC
diff --git a/examples/all-devices-app/posix/args.gni b/examples/all-devices-app/posix/args.gni
index 48a69bb..0f9056a 100644
--- a/examples/all-devices-app/posix/args.gni
+++ b/examples/all-devices-app/posix/args.gni
@@ -28,6 +28,8 @@
 
 chip_config_enable_groupcast = true
 
+chip_device_config_enable_wifi_pdc = true
+
 matter_enable_tracing_support = true
 matter_log_json_payload_decode_full = true
 matter_log_json_payload_hex = true
diff --git a/examples/all-devices-app/posix/darwin/BUILD.gn b/examples/all-devices-app/posix/darwin/BUILD.gn
index 5745673..2353b52 100644
--- a/examples/all-devices-app/posix/darwin/BUILD.gn
+++ b/examples/all-devices-app/posix/darwin/BUILD.gn
@@ -51,5 +51,9 @@
     "${chip_root}/zzz_generated/app-common/devices",
   ]
 
+  # Public because DeviceInstances.h includes AppRootNode.h. Needed for gn check
+  # even though Darwin doesn't offer --wifi-emulated.
+  public_deps = [ "${chip_root}/examples/all-devices-app/posix/emulated_network:emulated-wifi-driver" ]
+
   public_configs = [ ":includes" ]
 }
diff --git a/examples/all-devices-app/posix/emulated_network/BUILD.gn b/examples/all-devices-app/posix/emulated_network/BUILD.gn
new file mode 100644
index 0000000..a39ce3e
--- /dev/null
+++ b/examples/all-devices-app/posix/emulated_network/BUILD.gn
@@ -0,0 +1,36 @@
+# Copyright (c) 2026 Project CHIP 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("//build_overrides/build.gni")
+import("//build_overrides/chip.gni")
+
+source_set("emulated-wifi-driver") {
+  sources = [
+    "EmulatedWiFiDriver.cpp",
+    "EmulatedWiFiDriver.h",
+  ]
+
+  public_deps = [
+    "${chip_root}/src/credentials",
+    "${chip_root}/src/crypto",
+    "${chip_root}/src/lib/support",
+    "${chip_root}/src/platform",
+  ]
+
+  public_configs = [ ":includes" ]
+}
+
+config("includes") {
+  include_dirs = [ "${chip_root}/examples/all-devices-app" ]
+}
diff --git a/examples/all-devices-app/posix/emulated_network/EmulatedWiFiDriver.cpp b/examples/all-devices-app/posix/emulated_network/EmulatedWiFiDriver.cpp
new file mode 100644
index 0000000..86b8bab
--- /dev/null
+++ b/examples/all-devices-app/posix/emulated_network/EmulatedWiFiDriver.cpp
@@ -0,0 +1,354 @@
+/*
+ *    Copyright (c) 2026 Project CHIP 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.
+ */
+
+#include <posix/emulated_network/EmulatedWiFiDriver.h>
+
+#include <lib/support/CodeUtils.h>
+#include <lib/support/StringBuilder.h>
+#include <lib/support/logging/CHIPLogging.h>
+
+#include <algorithm>
+
+using namespace chip::DeviceLayer::NetworkCommissioning;
+
+namespace chip::app {
+
+namespace {
+
+[[maybe_unused]] NullTerminated SsidForLog(ByteSpan ssid)
+{
+    return NullTerminated(reinterpret_cast<const char *>(ssid.data()), ssid.size());
+}
+
+#if CHIP_DEVICE_CONFIG_ENABLE_WIFI_PDC
+void ExtractKeyIdentifier(Optional<Credentials::CertificateKeyIdStorage> & identifier, ByteSpan identity)
+{
+    if (Credentials::ExtractIdentifierFromChipNetworkIdentity(identity, identifier.Emplace()) != CHIP_NO_ERROR)
+    {
+        identifier.ClearValue();
+    }
+}
+#endif // CHIP_DEVICE_CONFIG_ENABLE_WIFI_PDC
+
+} // namespace
+
+class EmulatedWiFiDriver::NetworkIterator final : public DeviceLayer::NetworkCommissioning::NetworkIterator
+{
+public:
+    explicit NetworkIterator(EmulatedWiFiDriver & driver) : mDriver(driver) {}
+
+    size_t Count() override { return mDriver.mStaged.size(); }
+
+    bool Next(Network & item) override
+    {
+        VerifyOrReturnValue(mIndex < mDriver.mStaged.size(), false);
+        const NetworkEntry & network = mDriver.mStaged[mIndex++];
+
+        memcpy(item.networkID, network.ssid, network.ssidLen);
+        item.networkIDLen = network.ssidLen;
+        item.connected    = mDriver.IsConnectedTo(network.Ssid());
+#if CHIP_DEVICE_CONFIG_ENABLE_WIFI_PDC
+        item.networkIdentifier.ClearValue();
+        item.clientIdentifier.ClearValue();
+        if (network.UsingPDC())
+        {
+            ExtractKeyIdentifier(item.networkIdentifier, ByteSpan(network.networkIdentity, network.networkIdentityLen));
+            ExtractKeyIdentifier(item.clientIdentifier, ByteSpan(network.clientIdentity, network.clientIdentityLen));
+        }
+#endif // CHIP_DEVICE_CONFIG_ENABLE_WIFI_PDC
+        return true;
+    }
+
+    void Release() override { delete this; }
+
+private:
+    EmulatedWiFiDriver & mDriver;
+    size_t mIndex = 0;
+};
+
+CHIP_ERROR EmulatedWiFiDriver::Init(NetworkStatusChangeCallback * /* networkStatusChangeCallback */)
+{
+    ChipLogProgress(NetworkProvisioning, "EmulatedWiFiDriver: Init");
+    mStaged = mCommitted;
+    return CHIP_NO_ERROR;
+}
+
+void EmulatedWiFiDriver::Shutdown() {}
+
+DeviceLayer::NetworkCommissioning::NetworkIterator * EmulatedWiFiDriver::GetNetworks()
+{
+    // The cluster always consumes and releases the iterator synchronously, so mStaged
+    // cannot change while the iterator is in use.
+    return new NetworkIterator(*this);
+}
+
+CHIP_ERROR EmulatedWiFiDriver::CommitConfiguration()
+{
+    ChipLogProgress(NetworkProvisioning, "EmulatedWiFiDriver: CommitConfiguration (%u networks)",
+                    static_cast<unsigned>(mStaged.size()));
+    mCommitted = mStaged;
+    return CHIP_NO_ERROR;
+}
+
+CHIP_ERROR EmulatedWiFiDriver::RevertConfiguration()
+{
+    ChipLogProgress(NetworkProvisioning, "EmulatedWiFiDriver: RevertConfiguration (%u networks)",
+                    static_cast<unsigned>(mCommitted.size()));
+    mStaged = mCommitted;
+    if (mConnectedSsidLen != 0 && FindStagedNetwork(ByteSpan(mConnectedSsid, mConnectedSsidLen)) == nullptr)
+    {
+        mConnectedSsidLen = 0;
+    }
+    return CHIP_NO_ERROR;
+}
+
+Status EmulatedWiFiDriver::AddOrUpdateNetwork(ByteSpan ssid, ByteSpan credentials, MutableCharSpan & outDebugText,
+                                              uint8_t & outNetworkIndex)
+{
+    outDebugText.reduce_size(0);
+    VerifyOrReturnError(credentials.size() <= sizeof(NetworkEntry::credentials), Status::kOutOfRange);
+
+    uint8_t index;
+    NetworkEntry network;
+    Status status = PrepareAddOrUpdate(ssid, index, network);
+    VerifyOrReturnValue(status == Status::kSuccess, status);
+
+    memcpy(network.credentials, credentials.data(), credentials.size());
+    network.credentialsLen = static_cast<uint8_t>(credentials.size());
+#if CHIP_DEVICE_CONFIG_ENABLE_WIFI_PDC
+    network.networkIdentityLen = 0;
+    network.clientIdentityLen  = 0;
+    network.clientIdentityKeypair.reset();
+#endif // CHIP_DEVICE_CONFIG_ENABLE_WIFI_PDC
+
+    StoreStagedNetwork(index, network);
+    outNetworkIndex = index;
+    ChipLogProgress(NetworkProvisioning, "EmulatedWiFiDriver: AddOrUpdateNetwork '%s' at index %u", SsidForLog(ssid).c_str(),
+                    index);
+    return Status::kSuccess;
+}
+
+Status EmulatedWiFiDriver::RemoveNetwork(ByteSpan networkId, MutableCharSpan & outDebugText, uint8_t & outNetworkIndex)
+{
+    outDebugText.reduce_size(0);
+    uint8_t index;
+    VerifyOrReturnError(FindStagedNetwork(networkId, &index) != nullptr, Status::kNetworkIDNotFound);
+
+    // Like a real station, removing the network configuration does not disconnect.
+    mStaged.erase(mStaged.begin() + index);
+    outNetworkIndex = index;
+    ChipLogProgress(NetworkProvisioning, "EmulatedWiFiDriver: RemoveNetwork '%s' at index %u", SsidForLog(networkId).c_str(),
+                    index);
+    return Status::kSuccess;
+}
+
+Status EmulatedWiFiDriver::ReorderNetwork(ByteSpan networkId, uint8_t index, MutableCharSpan & outDebugText)
+{
+    outDebugText.reduce_size(0);
+    uint8_t currentIndex;
+    VerifyOrReturnError(FindStagedNetwork(networkId, &currentIndex) != nullptr, Status::kNetworkIDNotFound);
+    VerifyOrReturnError(index < mStaged.size(), Status::kOutOfRange);
+
+    auto current = mStaged.begin() + currentIndex;
+    auto target  = mStaged.begin() + index;
+    if (currentIndex < index)
+    {
+        std::rotate(current, current + 1, target + 1);
+    }
+    else
+    {
+        std::rotate(target, current, current + 1);
+    }
+    ChipLogProgress(NetworkProvisioning, "EmulatedWiFiDriver: ReorderNetwork '%s' from index %u to %u",
+                    SsidForLog(networkId).c_str(), currentIndex, index);
+    return Status::kSuccess;
+}
+
+void EmulatedWiFiDriver::ConnectNetwork(ByteSpan networkId, ConnectCallback * callback)
+{
+    const NetworkEntry * network = FindStagedNetwork(networkId);
+    Status status                = (network != nullptr) ? ResolveConnectOutcome(*network) : Status::kNetworkIDNotFound;
+    if (status == Status::kSuccess)
+    {
+        memcpy(mConnectedSsid, network->ssid, network->ssidLen);
+        mConnectedSsidLen = network->ssidLen;
+    }
+
+    ChipLogProgress(NetworkProvisioning, "EmulatedWiFiDriver: ConnectNetwork '%s' -> %u", SsidForLog(networkId).c_str(),
+                    to_underlying(status));
+    callback->OnResult(status, CharSpan(), 0);
+}
+
+Status EmulatedWiFiDriver::ResolveConnectOutcome(const NetworkEntry & /* network */)
+{
+    return Status::kSuccess;
+}
+
+#if CHIP_DEVICE_CONFIG_SUPPORTS_CONCURRENT_CONNECTION
+CHIP_ERROR EmulatedWiFiDriver::DisconnectFromNetwork()
+{
+    if (mConnectedSsidLen != 0)
+    {
+        ChipLogProgress(NetworkProvisioning, "EmulatedWiFiDriver: DisconnectFromNetwork '%s'",
+                        SsidForLog(ByteSpan(mConnectedSsid, mConnectedSsidLen)).c_str());
+        mConnectedSsidLen = 0;
+    }
+    return CHIP_NO_ERROR;
+}
+#endif // CHIP_DEVICE_CONFIG_SUPPORTS_CONCURRENT_CONNECTION
+
+void EmulatedWiFiDriver::ScanNetworks(ByteSpan ssid, ScanCallback * callback)
+{
+    ChipLogProgress(NetworkProvisioning, "EmulatedWiFiDriver: ScanNetworks '%s' -> no results", SsidForLog(ssid).c_str());
+    callback->OnFinished(Status::kSuccess, CharSpan(), nullptr);
+}
+
+uint32_t EmulatedWiFiDriver::GetSupportedWiFiBandsMask() const
+{
+    return (1UL << to_underlying(WiFiBandEnum::k2g4)) | (1UL << to_underlying(WiFiBandEnum::k5g));
+}
+
+#if CHIP_DEVICE_CONFIG_ENABLE_WIFI_PDC
+CHIP_ERROR EmulatedWiFiDriver::AddOrUpdateNetworkWithPDC(ByteSpan ssid, ByteSpan networkIdentity,
+                                                         Optional<uint8_t> clientIdentityNetworkIndex, Status & outStatus,
+                                                         MutableCharSpan & outDebugText, MutableByteSpan & outClientIdentity,
+                                                         uint8_t & outNetworkIndex)
+{
+    outDebugText.reduce_size(0);
+    outStatus = Status::kOutOfRange;
+    VerifyOrReturnError(!networkIdentity.empty() && networkIdentity.size() <= sizeof(NetworkEntry::networkIdentity), CHIP_NO_ERROR);
+
+    const NetworkEntry * reusedIdentityNetwork = nullptr;
+    if (clientIdentityNetworkIndex.HasValue())
+    {
+        reusedIdentityNetwork = StagedPDCNetworkAt(clientIdentityNetworkIndex.Value());
+        VerifyOrReturnError(reusedIdentityNetwork != nullptr, CHIP_NO_ERROR);
+    }
+
+    uint8_t index;
+    NetworkEntry network;
+    outStatus = PrepareAddOrUpdate(ssid, index, network);
+    VerifyOrReturnError(outStatus == Status::kSuccess, CHIP_NO_ERROR);
+    outStatus = Status::kUnknownError;
+
+    network.credentialsLen = 0;
+    memcpy(network.networkIdentity, networkIdentity.data(), networkIdentity.size());
+    network.networkIdentityLen = static_cast<uint8_t>(networkIdentity.size());
+
+    const bool reuseClientIdentity = (reusedIdentityNetwork != nullptr);
+    if (reuseClientIdentity)
+    {
+        memcpy(network.clientIdentity, reusedIdentityNetwork->clientIdentity, reusedIdentityNetwork->clientIdentityLen);
+        network.clientIdentityLen     = reusedIdentityNetwork->clientIdentityLen;
+        network.clientIdentityKeypair = reusedIdentityNetwork->clientIdentityKeypair;
+    }
+    else
+    {
+        network.clientIdentityKeypair = Platform::MakeShared<Crypto::P256Keypair>();
+        VerifyOrReturnError(network.clientIdentityKeypair, CHIP_ERROR_NO_MEMORY);
+        ReturnErrorOnFailure(network.clientIdentityKeypair->Initialize(Crypto::ECPKeyTarget::ECDSA));
+
+        MutableByteSpan clientIdentity(network.clientIdentity);
+        ReturnErrorOnFailure(Credentials::NewChipNetworkIdentity(*network.clientIdentityKeypair, clientIdentity));
+        network.clientIdentityLen = static_cast<uint8_t>(clientIdentity.size());
+    }
+
+    ReturnErrorOnFailure(CopySpanToMutableSpan(ByteSpan(network.clientIdentity, network.clientIdentityLen), outClientIdentity));
+
+    StoreStagedNetwork(index, network);
+    outNetworkIndex = index;
+    outStatus       = Status::kSuccess;
+    ChipLogProgress(NetworkProvisioning, "EmulatedWiFiDriver: AddOrUpdateNetworkWithPDC '%s' at index %u (%s client identity)",
+                    SsidForLog(ssid).c_str(), index, reuseClientIdentity ? "reused" : "new");
+    return CHIP_NO_ERROR;
+}
+
+CHIP_ERROR EmulatedWiFiDriver::GetNetworkIdentity(uint8_t networkIndex, MutableByteSpan & outNetworkIdentity)
+{
+    const NetworkEntry * network = StagedPDCNetworkAt(networkIndex);
+    VerifyOrReturnError(network != nullptr, CHIP_ERROR_INVALID_ARGUMENT);
+    return CopySpanToMutableSpan(ByteSpan(network->networkIdentity, network->networkIdentityLen), outNetworkIdentity);
+}
+
+CHIP_ERROR EmulatedWiFiDriver::GetClientIdentity(uint8_t networkIndex, MutableByteSpan & outClientIdentity)
+{
+    const NetworkEntry * network = StagedPDCNetworkAt(networkIndex);
+    VerifyOrReturnError(network != nullptr, CHIP_ERROR_INVALID_ARGUMENT);
+    return CopySpanToMutableSpan(ByteSpan(network->clientIdentity, network->clientIdentityLen), outClientIdentity);
+}
+
+CHIP_ERROR EmulatedWiFiDriver::SignWithClientIdentity(uint8_t networkIndex, const ByteSpan & message,
+                                                      Crypto::P256ECDSASignature & outSignature)
+{
+    const NetworkEntry * network = StagedPDCNetworkAt(networkIndex);
+    VerifyOrReturnError(network != nullptr, CHIP_ERROR_INVALID_ARGUMENT);
+    return network->clientIdentityKeypair->ECDSA_sign_msg(message.data(), message.size(), outSignature);
+}
+
+const EmulatedWiFiDriver::NetworkEntry * EmulatedWiFiDriver::StagedPDCNetworkAt(uint8_t networkIndex) const
+{
+    VerifyOrReturnValue(networkIndex < mStaged.size() && mStaged[networkIndex].UsingPDC(), nullptr);
+    return &mStaged[networkIndex];
+}
+#endif // CHIP_DEVICE_CONFIG_ENABLE_WIFI_PDC
+
+const EmulatedWiFiDriver::NetworkEntry * EmulatedWiFiDriver::FindStagedNetwork(ByteSpan ssid, uint8_t * outIndex) const
+{
+    for (size_t i = 0; i < mStaged.size(); i++)
+    {
+        if (mStaged[i].Matches(ssid))
+        {
+            if (outIndex != nullptr)
+            {
+                *outIndex = static_cast<uint8_t>(i);
+            }
+            return &mStaged[i];
+        }
+    }
+    return nullptr;
+}
+
+Status EmulatedWiFiDriver::PrepareAddOrUpdate(ByteSpan ssid, uint8_t & outIndex, NetworkEntry & outNetwork) const
+{
+    VerifyOrReturnError(!ssid.empty() && ssid.size() <= sizeof(NetworkEntry::ssid), Status::kOutOfRange);
+
+    const NetworkEntry * existing = FindStagedNetwork(ssid, &outIndex);
+    if (existing != nullptr)
+    {
+        outNetwork = *existing;
+        return Status::kSuccess;
+    }
+
+    VerifyOrReturnError(mStaged.size() < mConfig.maxNetworks, Status::kBoundsExceeded);
+    outIndex = static_cast<uint8_t>(mStaged.size());
+    memcpy(outNetwork.ssid, ssid.data(), ssid.size());
+    outNetwork.ssidLen = static_cast<uint8_t>(ssid.size());
+    return Status::kSuccess;
+}
+
+void EmulatedWiFiDriver::StoreStagedNetwork(uint8_t index, const NetworkEntry & network)
+{
+    if (index < mStaged.size())
+    {
+        mStaged[index] = network;
+    }
+    else
+    {
+        mStaged.push_back(network);
+    }
+}
+
+} // namespace chip::app
diff --git a/examples/all-devices-app/posix/emulated_network/EmulatedWiFiDriver.h b/examples/all-devices-app/posix/emulated_network/EmulatedWiFiDriver.h
new file mode 100644
index 0000000..20e9814
--- /dev/null
+++ b/examples/all-devices-app/posix/emulated_network/EmulatedWiFiDriver.h
@@ -0,0 +1,148 @@
+/*
+ *    Copyright (c) 2026 Project CHIP 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.
+ */
+
+#pragma once
+
+#include <credentials/CHIPCert.h>
+#include <crypto/CHIPCryptoPAL.h>
+#include <lib/support/CHIPMem.h>
+#include <lib/support/Span.h>
+#include <platform/NetworkCommissioning.h>
+
+#include <cstdint>
+#include <vector>
+
+namespace chip::app {
+
+/**
+ * A WiFiDriver that keeps network configurations in memory and never touches a real
+ * network interface. ConnectNetwork succeeds for any configured network.
+ *
+ * This allows the Network Commissioning cluster to be exercised on a host that is
+ * already connected to the network by other means, e.g. a test harness machine.
+ *
+ * All methods must be called on the Matter thread. ConnectNetwork and ScanNetworks
+ * invoke their callback synchronously, before returning.
+ */
+class EmulatedWiFiDriver final : public DeviceLayer::NetworkCommissioning::WiFiDriver
+{
+public:
+    using Status = DeviceLayer::NetworkCommissioning::Status;
+
+    /**
+     * Behaviour settings. The driver reads these when it needs them, so they can be
+     * changed at runtime via `GetConfig()` unless noted otherwise.
+     */
+    struct Config
+    {
+        /// Reported as MaxNetworks. Lowering it does not remove existing networks.
+        uint8_t maxNetworks = 1;
+#if CHIP_DEVICE_CONFIG_ENABLE_WIFI_PDC
+        /// The cluster reads this once when it is constructed, so changes after
+        /// that have no effect.
+        bool supportsPerDeviceCredentials = true;
+#endif
+    };
+
+    EmulatedWiFiDriver() = default;
+    explicit EmulatedWiFiDriver(const Config & config) : mConfig(config) {}
+
+    Config & GetConfig() { return mConfig; }
+
+    // BaseDriver
+    CHIP_ERROR Init(NetworkStatusChangeCallback * networkStatusChangeCallback) override;
+    void Shutdown() override;
+    DeviceLayer::NetworkCommissioning::NetworkIterator * GetNetworks() override;
+
+    // WirelessDriver
+    uint8_t GetMaxNetworks() override { return mConfig.maxNetworks; }
+    uint8_t GetScanNetworkTimeoutSeconds() override { return 10; }
+    uint8_t GetConnectNetworkTimeoutSeconds() override { return 20; }
+    CHIP_ERROR CommitConfiguration() override;
+    CHIP_ERROR RevertConfiguration() override;
+    Status RemoveNetwork(ByteSpan networkId, MutableCharSpan & outDebugText, uint8_t & outNetworkIndex) override;
+    Status ReorderNetwork(ByteSpan networkId, uint8_t index, MutableCharSpan & outDebugText) override;
+    void ConnectNetwork(ByteSpan networkId, ConnectCallback * callback) override;
+#if CHIP_DEVICE_CONFIG_SUPPORTS_CONCURRENT_CONNECTION
+    CHIP_ERROR DisconnectFromNetwork() override;
+#endif
+
+    // WiFiDriver
+    Status AddOrUpdateNetwork(ByteSpan ssid, ByteSpan credentials, MutableCharSpan & outDebugText,
+                              uint8_t & outNetworkIndex) override;
+    void ScanNetworks(ByteSpan ssid, ScanCallback * callback) override;
+    uint32_t GetSupportedWiFiBandsMask() const override;
+#if CHIP_DEVICE_CONFIG_ENABLE_WIFI_PDC
+    bool SupportsPerDeviceCredentials() override { return mConfig.supportsPerDeviceCredentials; }
+    CHIP_ERROR AddOrUpdateNetworkWithPDC(ByteSpan ssid, ByteSpan networkIdentity, Optional<uint8_t> clientIdentityNetworkIndex,
+                                         Status & outStatus, MutableCharSpan & outDebugText, MutableByteSpan & outClientIdentity,
+                                         uint8_t & outNetworkIndex) override;
+    CHIP_ERROR GetNetworkIdentity(uint8_t networkIndex, MutableByteSpan & outNetworkIdentity) override;
+    CHIP_ERROR GetClientIdentity(uint8_t networkIndex, MutableByteSpan & outClientIdentity) override;
+    CHIP_ERROR SignWithClientIdentity(uint8_t networkIndex, const ByteSpan & message,
+                                      Crypto::P256ECDSASignature & outSignature) override;
+#endif // CHIP_DEVICE_CONFIG_ENABLE_WIFI_PDC
+
+private:
+    struct NetworkEntry
+    {
+        ByteSpan Ssid() const { return ByteSpan(ssid, ssidLen); }
+        bool Matches(ByteSpan aSsid) const { return Ssid().data_equal(aSsid); }
+
+        uint8_t ssid[DeviceLayer::Internal::kMaxWiFiSSIDLength];
+        uint8_t ssidLen = 0;
+
+        uint8_t credentials[DeviceLayer::Internal::kMaxWiFiKeyLength];
+        uint8_t credentialsLen = 0;
+
+#if CHIP_DEVICE_CONFIG_ENABLE_WIFI_PDC
+        bool UsingPDC() const { return networkIdentityLen != 0; }
+
+        uint8_t networkIdentity[Credentials::kMaxCHIPCompactNetworkIdentityLength];
+        uint8_t networkIdentityLen = 0;
+
+        uint8_t clientIdentity[Credentials::kMaxCHIPCompactNetworkIdentityLength];
+        uint8_t clientIdentityLen = 0;
+
+        // Shared between copies of the entry; the keypair never changes after creation.
+        Platform::SharedPtr<Crypto::P256Keypair> clientIdentityKeypair;
+#endif // CHIP_DEVICE_CONFIG_ENABLE_WIFI_PDC
+    };
+
+    class NetworkIterator;
+
+    const NetworkEntry * FindStagedNetwork(ByteSpan ssid, uint8_t * outIndex = nullptr) const;
+    // Validates the SSID and finds the staged entry to update, or the index to append at.
+    // outNetwork is a copy of the existing entry, or a new entry with only the SSID set.
+    Status PrepareAddOrUpdate(ByteSpan ssid, uint8_t & outIndex, NetworkEntry & outNetwork) const;
+    void StoreStagedNetwork(uint8_t index, const NetworkEntry & network);
+#if CHIP_DEVICE_CONFIG_ENABLE_WIFI_PDC
+    const NetworkEntry * StagedPDCNetworkAt(uint8_t networkIndex) const;
+#endif
+    bool IsConnectedTo(ByteSpan ssid) const
+    {
+        return mConnectedSsidLen != 0 && ByteSpan(mConnectedSsid, mConnectedSsidLen).data_equal(ssid);
+    }
+    Status ResolveConnectOutcome(const NetworkEntry & network);
+
+    Config mConfig;
+    std::vector<NetworkEntry> mCommitted;
+    std::vector<NetworkEntry> mStaged; // edited by commands and reported by GetNetworks
+    uint8_t mConnectedSsid[DeviceLayer::Internal::kMaxWiFiSSIDLength];
+    uint8_t mConnectedSsidLen = 0; // 0 when not connected
+};
+
+} // namespace chip::app
diff --git a/examples/all-devices-app/posix/emulated_network/tests/BUILD.gn b/examples/all-devices-app/posix/emulated_network/tests/BUILD.gn
new file mode 100644
index 0000000..f009a34
--- /dev/null
+++ b/examples/all-devices-app/posix/emulated_network/tests/BUILD.gn
@@ -0,0 +1,33 @@
+# Copyright (c) 2026 Project CHIP 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("//build_overrides/chip.gni")
+
+import("${chip_root}/build/chip/chip_test_suite.gni")
+import("${chip_root}/build/chip/tests.gni")
+
+if (chip_build_tests) {
+  chip_test_suite("tests") {
+    output_name = "TestEmulatedWiFiDriver"
+
+    test_sources = [ "TestEmulatedWiFiDriver.cpp" ]
+
+    cflags = [ "-Wconversion" ]
+
+    public_deps = [
+      "${chip_root}/examples/all-devices-app/posix/emulated_network:emulated-wifi-driver",
+      "${chip_root}/src/lib/support:testing",
+    ]
+  }
+}
diff --git a/examples/all-devices-app/posix/emulated_network/tests/TestEmulatedWiFiDriver.cpp b/examples/all-devices-app/posix/emulated_network/tests/TestEmulatedWiFiDriver.cpp
new file mode 100644
index 0000000..4b4e4a3
--- /dev/null
+++ b/examples/all-devices-app/posix/emulated_network/tests/TestEmulatedWiFiDriver.cpp
@@ -0,0 +1,175 @@
+/*
+ *    Copyright (c) 2026 Project CHIP 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.
+ */
+
+#include <posix/emulated_network/EmulatedWiFiDriver.h>
+
+#include <lib/support/CHIPMem.h>
+#include <lib/support/Span.h>
+#include <pw_unit_test/framework.h>
+
+#include <optional>
+
+using namespace chip;
+using namespace chip::app;
+using namespace chip::DeviceLayer::NetworkCommissioning;
+
+namespace {
+
+ByteSpan Bytes(CharSpan text)
+{
+    return ByteSpan(Uint8::from_const_char(text.data()), text.size());
+}
+
+const ByteSpan kSsid1 = Bytes("network-1"_span);
+const ByteSpan kSsid2 = Bytes("network-2"_span);
+const ByteSpan kSsid3 = Bytes("network-3"_span);
+
+class ConnectResult : public WiFiDriver::ConnectCallback
+{
+public:
+    void OnResult(Status status, CharSpan /* debugText */, int32_t /* connectStatus */) override { result = status; }
+
+    std::optional<Status> result;
+};
+
+class TestEmulatedWiFiDriver : public ::testing::Test
+{
+public:
+    static void SetUpTestSuite() { ASSERT_EQ(Platform::MemoryInit(), CHIP_NO_ERROR); }
+    static void TearDownTestSuite() { Platform::MemoryShutdown(); }
+
+    void SetUp() override { ASSERT_EQ(mDriver.Init(nullptr), CHIP_NO_ERROR); }
+    void TearDown() override { mDriver.Shutdown(); }
+
+protected:
+    Status Add(ByteSpan ssid)
+    {
+        char debugTextBuffer[64];
+        MutableCharSpan debugText(debugTextBuffer);
+        uint8_t index;
+        return mDriver.AddOrUpdateNetwork(ssid, Bytes("password"_span), debugText, index);
+    }
+
+    std::optional<bool> IsConnected(ByteSpan ssid)
+    {
+        std::optional<bool> connected;
+        NetworkIterator * networks = mDriver.GetNetworks();
+        Network network;
+        while (networks->Next(network))
+        {
+            if (ByteSpan(network.networkID, network.networkIDLen).data_equal(ssid))
+            {
+                connected = network.connected;
+            }
+        }
+        networks->Release();
+        return connected;
+    }
+
+    EmulatedWiFiDriver mDriver{ EmulatedWiFiDriver::Config{ .maxNetworks = 3 } };
+};
+
+TEST_F(TestEmulatedWiFiDriver, ConnectReportsResultSynchronously)
+{
+    ConnectResult unknown;
+    mDriver.ConnectNetwork(kSsid1, &unknown);
+    EXPECT_EQ(unknown.result, Status::kNetworkIDNotFound);
+
+    ASSERT_EQ(Add(kSsid1), Status::kSuccess);
+    ASSERT_EQ(Add(kSsid2), Status::kSuccess);
+    EXPECT_EQ(IsConnected(kSsid1), false);
+
+    ConnectResult known;
+    mDriver.ConnectNetwork(kSsid1, &known);
+    EXPECT_EQ(known.result, Status::kSuccess);
+    EXPECT_EQ(IsConnected(kSsid1), true);
+    EXPECT_EQ(IsConnected(kSsid2), false);
+}
+
+TEST_F(TestEmulatedWiFiDriver, RevertRestoresCommittedNetworksAndConnection)
+{
+    ASSERT_EQ(Add(kSsid1), Status::kSuccess);
+    ASSERT_EQ(mDriver.CommitConfiguration(), CHIP_NO_ERROR);
+
+    ASSERT_EQ(Add(kSsid2), Status::kSuccess);
+    ConnectResult result;
+    mDriver.ConnectNetwork(kSsid2, &result);
+    ASSERT_EQ(result.result, Status::kSuccess);
+
+    // The connected network was never committed, so reverting also drops the connection.
+    ASSERT_EQ(mDriver.RevertConfiguration(), CHIP_NO_ERROR);
+    EXPECT_EQ(IsConnected(kSsid1), false);
+    EXPECT_EQ(IsConnected(kSsid2), std::nullopt);
+
+    mDriver.ConnectNetwork(kSsid1, &result);
+    ASSERT_EQ(result.result, Status::kSuccess);
+    ASSERT_EQ(mDriver.RevertConfiguration(), CHIP_NO_ERROR);
+    EXPECT_EQ(IsConnected(kSsid1), true);
+}
+
+#if CHIP_DEVICE_CONFIG_ENABLE_WIFI_PDC
+TEST_F(TestEmulatedWiFiDriver, PDCClientIdentityCanBeReused)
+{
+    // Any well-formed compact identity will do for the network identity.
+    uint8_t networkIdentityBuffer[Credentials::kMaxCHIPCompactNetworkIdentityLength];
+    MutableByteSpan networkIdentity(networkIdentityBuffer);
+    {
+        Crypto::P256Keypair keypair;
+        ASSERT_EQ(keypair.Initialize(Crypto::ECPKeyTarget::ECDSA), CHIP_NO_ERROR);
+        ASSERT_EQ(Credentials::NewChipNetworkIdentity(keypair, networkIdentity), CHIP_NO_ERROR);
+    }
+
+    auto addWithPDC = [&](ByteSpan ssid, Optional<uint8_t> reuseIndex, MutableByteSpan & clientIdentity, uint8_t & index) {
+        Status status;
+        char debugTextBuffer[64];
+        MutableCharSpan debugText(debugTextBuffer);
+        EXPECT_EQ(mDriver.AddOrUpdateNetworkWithPDC(ssid, networkIdentity, reuseIndex, status, debugText, clientIdentity, index),
+                  CHIP_NO_ERROR);
+        return status;
+    };
+
+    uint8_t identity1Buffer[Credentials::kMaxCHIPCompactNetworkIdentityLength];
+    uint8_t identity2Buffer[Credentials::kMaxCHIPCompactNetworkIdentityLength];
+    uint8_t identity3Buffer[Credentials::kMaxCHIPCompactNetworkIdentityLength];
+    MutableByteSpan identity1(identity1Buffer);
+    MutableByteSpan identity2(identity2Buffer);
+    MutableByteSpan identity3(identity3Buffer);
+    uint8_t index1, index2, index3;
+
+    ASSERT_EQ(addWithPDC(kSsid1, NullOptional, identity1, index1), Status::kSuccess);
+    ASSERT_EQ(addWithPDC(kSsid2, MakeOptional(index1), identity2, index2), Status::kSuccess);
+    EXPECT_TRUE(identity2.data_equal(identity1));
+
+    // Updating a network without reuse generates a new client identity.
+    ASSERT_EQ(addWithPDC(kSsid2, NullOptional, identity3, index3), Status::kSuccess);
+    EXPECT_EQ(index3, index2);
+    EXPECT_FALSE(identity3.data_equal(identity1));
+
+    // A reused identity must refer to an existing PDC network.
+    MutableByteSpan unused(identity3Buffer);
+    EXPECT_EQ(addWithPDC(kSsid3, MakeOptional(static_cast<uint8_t>(5)), unused, index3), Status::kOutOfRange);
+    ASSERT_EQ(Add(kSsid1), Status::kSuccess); // replaces PDC with a passphrase
+    EXPECT_EQ(addWithPDC(kSsid3, MakeOptional(index1), unused, index3), Status::kOutOfRange);
+
+    uint8_t clientIdentityBuffer[Credentials::kMaxCHIPCompactNetworkIdentityLength];
+    MutableByteSpan clientIdentity(clientIdentityBuffer);
+    EXPECT_EQ(mDriver.GetClientIdentity(index1, clientIdentity), CHIP_ERROR_INVALID_ARGUMENT);
+    ASSERT_EQ(mDriver.GetClientIdentity(index2, clientIdentity), CHIP_NO_ERROR);
+    EXPECT_TRUE(clientIdentity.data_equal(identity3));
+}
+#endif // CHIP_DEVICE_CONFIG_ENABLE_WIFI_PDC
+
+} // namespace
diff --git a/examples/all-devices-app/posix/include/AppRootNode.h b/examples/all-devices-app/posix/include/AppRootNode.h
index a17c0a5..ff1383c 100644
--- a/examples/all-devices-app/posix/include/AppRootNode.h
+++ b/examples/all-devices-app/posix/include/AppRootNode.h
@@ -30,6 +30,9 @@
 #else
 #include <platform/Linux/NetworkCommissioningDriver.h>
 #endif
+#if CHIP_DEVICE_CONFIG_ENABLE_WIFI_STATION
+#include <posix/emulated_network/EmulatedWiFiDriver.h>
+#endif
 #else
 #include <device/types/root-node/RootNode.h>
 #endif
@@ -47,14 +50,15 @@
     {
         kNone = 0,
 #if CHIP_DEVICE_CONFIG_ENABLE_WIFI
-        kWiFi = 0x01, // enable wifi support
+        kWiFi         = 0x01, // enable wifi support
+        kEmulatedWiFi = 0x02, // with kWiFi: use EmulatedWiFiDriver instead of the platform driver
 #endif
     };
 
     AppRootNode(const RootNode::Context & context, BitFlags<EnabledFeatures> features = {}) :
         mEnabledFeatures(features),
 #if CHIP_DEVICE_CONFIG_ENABLE_WIFI
-        mWiFiRootNode(context, WifiFeature::Context{ .wifiDriver = mWiFiDriver }),
+        mWiFiRootNode(context, WifiFeature::Context{ .wifiDriver = SelectedWiFiDriver() }),
 #endif // CHIP_DEVICE_CONFIG_ENABLE_WIFI
         mRootNode(context)
     {
@@ -77,6 +81,19 @@
     RootNode & GetRootNode() { return RootDevice(); }
 
 private:
+#if CHIP_DEVICE_CONFIG_ENABLE_WIFI
+    DeviceLayer::NetworkCommissioning::WiFiDriver & SelectedWiFiDriver()
+    {
+#if CHIP_DEVICE_CONFIG_ENABLE_WIFI_STATION
+        if (mEnabledFeatures.Has(EnabledFeatures::kEmulatedWiFi))
+        {
+            return mEmulatedWiFiDriver;
+        }
+#endif // CHIP_DEVICE_CONFIG_ENABLE_WIFI_STATION
+        return mWiFiDriver;
+    }
+#endif // CHIP_DEVICE_CONFIG_ENABLE_WIFI
+
     BitFlags<EnabledFeatures> mEnabledFeatures;
 
 #if CHIP_DEVICE_CONFIG_ENABLE_WIFI
@@ -86,6 +103,9 @@
 #else
     DeviceLayer::NetworkCommissioning::LinuxWiFiDriver mWiFiDriver;
 #endif // CHIP_DEVICE_LAYER_TARGET_DARWIN
+#if CHIP_DEVICE_CONFIG_ENABLE_WIFI_STATION
+    EmulatedWiFiDriver mEmulatedWiFiDriver;
+#endif // CHIP_DEVICE_CONFIG_ENABLE_WIFI_STATION
 
     WifiRootNode mWiFiRootNode;
 #endif // CHIP_DEVICE_CONFIG_ENABLE_WIFI
diff --git a/examples/all-devices-app/posix/linux/BUILD.gn b/examples/all-devices-app/posix/linux/BUILD.gn
index d00dc7d..6e023c4 100644
--- a/examples/all-devices-app/posix/linux/BUILD.gn
+++ b/examples/all-devices-app/posix/linux/BUILD.gn
@@ -63,7 +63,8 @@
 
   deps += [ "${chip_root}/third_party/jsoncpp" ]
 
-  public_deps = []
+  # Public because DeviceInstances.h includes AppRootNode.h.
+  public_deps = [ "${chip_root}/examples/all-devices-app/posix/emulated_network:emulated-wifi-driver" ]
 
   # The proxy device itself is here rather than in the unconditional list
   # because a build with no transport does not register it with the factory, and
diff --git a/examples/all-devices-app/posix/main.cpp b/examples/all-devices-app/posix/main.cpp
index 8e3eda8..9f2ea8b 100644
--- a/examples/all-devices-app/posix/main.cpp
+++ b/examples/all-devices-app/posix/main.cpp
@@ -183,7 +183,8 @@
             .timerDelegate              = gTimerDelegate,                                          //
             .minGuaranteedSubscriptionsPerFabric =
                 InteractionModelEngine::GetInstance()->GetMinGuaranteedSubscriptionsPerFabric(), //
-            .enableWiFi = AppOptions::GetConfig().enableWiFi,                                    //
+            .enableWiFi  = AppOptions::GetConfig().enableWiFi,                                   //
+            .emulateWiFi = AppOptions::GetConfig().emulateWiFi,                                  //
 
 #if CHIP_CONFIG_TERMS_AND_CONDITIONS_REQUIRED
             .termsAndConditionsProvider = TermsAndConditionsManager::GetInstance(),
@@ -422,7 +423,7 @@
     // Synchronous on purpose. The Wi-Fi PAF publish that follows starts management
     // itself if it is not up yet, and a second start while the first is still
     // completing deadlocks.
-    if (config.enableWiFi)
+    if (config.enableWiFi && !config.emulateWiFi)
     {
         LogErrorOnFailure(DeviceLayer::ConnectivityMgrImpl().StartWiFiManagementSync());
     }
diff --git a/src/python_testing/TC_CNET_4_28.py b/src/python_testing/TC_CNET_4_28.py
new file mode 100644
index 0000000..9a26955
--- /dev/null
+++ b/src/python_testing/TC_CNET_4_28.py
@@ -0,0 +1,186 @@
+#
+#    Copyright (c) 2026 Project CHIP Authors
+#    All rights reserved.
+#
+#    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 os
+
+from mobly import asserts
+from support_modules.cnet_pdc_support import FAILSAFE_EXPIRY_SECONDS, CNETPDCBaseTest, cnet
+from support_modules.network_identity import POSSESSION_NONCE_LENGTH, generate_network_client_identity, network_identity_identifier
+
+import matter.clusters as Clusters
+from matter.interaction_model import Status
+from matter.testing.decorators import has_feature, pics, run_if_endpoint_matches
+from matter.testing.runner import default_matter_test_main
+
+log = logging.getLogger(__name__)
+
+# SSIDs for the network configurations this test case adds. The corresponding Wi-Fi networks do
+# not need to exist, since the test case never invokes ConnectNetwork.
+_PDC_SSID_1 = b"TC-CNET-4.28-PDC-1"
+_PDC_SSID_2 = b"TC-CNET-4.28-PDC-2"
+
+
+class TC_CNET_4_28(CNETPDCBaseTest):
+
+    @pics("CNET.S.F00", "CNET.S.F03", "CNET.S.C09.Rsp")
+    @run_if_endpoint_matches(has_feature(Clusters.NetworkCommissioning,
+                                         Clusters.NetworkCommissioning.Bitmaps.Feature.kPerDeviceCredentials))
+    async def test_TC_CNET_4_28(self):
+        """[TC-CNET-4.28] [Wi-Fi] Verification for Network Client Identity reuse across network configurations [DUT-Server]"""
+        endpoint = self.get_endpoint()
+
+        self.step("precondition",
+                  "DUT is commissioned on PIXIT.CNET.WIFI_1ST_ACCESSPOINT_SSID. TH generates the PDC material used by "
+                  "this test case: two self-signed Network Identities NI_1 and NI_2 with the associated key "
+                  "identifiers NI_1_ID and NI_2_ID, and a random 32 octet nonce Nonce_1.")
+        _, ni_1 = generate_network_client_identity()
+        _, ni_2 = generate_network_client_identity()
+        ni_1_id = network_identity_identifier(ni_1)
+        ni_2_id = network_identity_identifier(ni_2)
+        nonce_1 = os.urandom(POSSESSION_NONCE_LENGTH)
+
+        self.step(1, "TH reads the FeatureMap attribute.",
+                  expectation="The PDC flag (bit 3) is set, otherwise the test case is skipped.")
+        # Reaching this step means the PDC flag is set: the decorator skips the test case otherwise.
+
+        self.step(2, "TH reads the MaxNetworks attribute and saves it as MaxNetworks. If MaxNetworks is less than 2, "
+                     "the remaining steps are skipped. TH reads the Networks attribute and saves it as "
+                     "InitialNetworks, with the number of entries as NumNetworks.",
+                  expectation="DUT responds with a success status.")
+        max_networks = await self.read_single_attribute_check_success(
+            endpoint=endpoint, cluster=cnet, attribute=cnet.Attributes.MaxNetworks)
+        if max_networks < 2:
+            log.info("MaxNetworks is %d, so the DUT cannot hold two network configurations at once.", max_networks)
+            self.mark_all_remaining_steps_skipped(3)
+            return
+        initial_networks = await self.read_networks(endpoint)
+        for ssid in (_PDC_SSID_1, _PDC_SSID_2):
+            asserts.assert_not_in(ssid, [n.networkID for n in initial_networks],
+                                  f"The DUT already has a network configuration for {ssid!r}.")
+
+        self.step(3, "TH sends ArmFailSafe to the DUT with ExpiryLengthSeconds set to 60.",
+                  expectation="DUT sends an ArmFailSafeResponse.")
+        await self.arm_failsafe(FAILSAFE_EXPIRY_SECONDS)
+
+        self.step(4, "TH sends RemoveNetwork for as many trailing entries of InitialNetworks as are needed to leave at "
+                     "least two free entries in the Networks attribute.",
+                  expectation="DUT sends a NetworkConfigResponse with NetworkingStatus Success to each command.")
+        # Everything this test case changes is undone by the fail-safe revert in step 12, so the
+        # removed entries only need to be counted, not remembered.
+        remaining = list(initial_networks)
+        while len(remaining) > max_networks - 2:
+            victim = remaining.pop()
+            response = await self.send_single_cmd(
+                cmd=cnet.Commands.RemoveNetwork(networkID=victim.networkID), endpoint=endpoint)
+            self.assert_network_config_success(response, f"RemoveNetwork for {victim.networkID!r}")
+        if len(remaining) == len(initial_networks):
+            log.info("Networks has %d of %d entries free, so no network configuration needs to be removed.",
+                     max_networks - len(initial_networks), max_networks)
+
+        self.step(5, "TH sends AddOrUpdateWiFiNetwork with the SSID field set to PDC_SSID_1, the Credentials field set "
+                     "to an empty octet string, the NetworkIdentity field set to NI_1, and the ClientIdentifier and "
+                     "PossessionNonce fields absent.",
+                  expectation="DUT sends a NetworkConfigResponse with NetworkingStatus Success and a ClientIdentity "
+                              "saved as NCI_A, with the associated key identifier saved as NCI_A_ID.")
+        response = await self.send_single_cmd(
+            cmd=cnet.Commands.AddOrUpdateWiFiNetwork(ssid=_PDC_SSID_1, credentials=b"", networkIdentity=ni_1),
+            endpoint=endpoint)
+        self.assert_network_config_success(response, f"AddOrUpdateWiFiNetwork for {_PDC_SSID_1!r}")
+        nci_a = response.clientIdentity
+        self.assert_valid_identity(nci_a, "NetworkConfigResponse.ClientIdentity")
+        nci_a_id = network_identity_identifier(nci_a)
+
+        self.step(6, "TH sends AddOrUpdateWiFiNetwork with the SSID field set to PDC_SSID_2, the Credentials field set "
+                     "to an empty octet string, the NetworkIdentity field set to NI_2, the ClientIdentifier field set "
+                     "to NCI_A_ID and the PossessionNonce field set to Nonce_1, i.e. the Network Client Identity is "
+                     "copied from the network configuration for PDC_SSID_1.",
+                  expectation="DUT sends a NetworkConfigResponse with NetworkingStatus Success, ClientIdentity NCI_A, "
+                              "and a PossessionSignature that is a valid ec-signature over (NCI_A || Nonce_1).")
+        response = await self.send_single_cmd(
+            cmd=cnet.Commands.AddOrUpdateWiFiNetwork(ssid=_PDC_SSID_2, credentials=b"", networkIdentity=ni_2,
+                                                     clientIdentifier=nci_a_id, possessionNonce=nonce_1),
+            endpoint=endpoint)
+        self.assert_network_config_success(response, f"AddOrUpdateWiFiNetwork for {_PDC_SSID_2!r} reusing the "
+                                           f"identity of {_PDC_SSID_1!r}")
+        asserts.assert_equal(response.clientIdentity, nci_a,
+                             "The DUT did not copy the Network Client Identity referenced by ClientIdentifier.")
+        self.assert_valid_possession_signature(nci_a, nonce_1, response.possessionSignature,
+                                               "NetworkConfigResponse.PossessionSignature")
+
+        self.step(7, "TH reads the Networks attribute.",
+                  expectation="The list contains an entry with NetworkID PDC_SSID_1, NetworkIdentifier NI_1_ID and "
+                              "ClientIdentifier NCI_A_ID, and an entry with NetworkID PDC_SSID_2, NetworkIdentifier "
+                              "NI_2_ID and ClientIdentifier NCI_A_ID.")
+        networks = await self.read_networks(endpoint)
+        for ssid, ni_id, ni_name in ((_PDC_SSID_1, ni_1_id, "NI_1"), (_PDC_SSID_2, ni_2_id, "NI_2")):
+            entry = self.find_network(networks, ssid)
+            asserts.assert_equal(entry.networkIdentifier, ni_id,
+                                 f"The NetworkIdentifier of {ssid!r} is not the key identifier of {ni_name}.")
+            asserts.assert_equal(entry.clientIdentifier, nci_a_id,
+                                 f"The ClientIdentifier of {ssid!r} is not the key identifier of NCI_A.")
+
+        self.step(8, "TH sends QueryIdentity with the KeyIdentifier field set to NCI_A_ID and the PossessionNonce "
+                     "field set to Nonce_1.",
+                  expectation="DUT sends a QueryIdentityResponse with Identity NCI_A and a PossessionSignature that is "
+                              "a valid ec-signature over (NCI_A || Nonce_1).")
+        response = await self.query_identity(endpoint, nci_a_id, nonce_1)
+        asserts.assert_equal(response.identity, nci_a, "QueryIdentity did not resolve NCI_A_ID to NCI_A.")
+        self.assert_valid_possession_signature(nci_a, nonce_1, response.possessionSignature,
+                                               "QueryIdentityResponse.PossessionSignature")
+
+        self.step(9, "TH sends RemoveNetwork with the NetworkID field set to PDC_SSID_1.",
+                  expectation="DUT sends a NetworkConfigResponse with NetworkingStatus Success.")
+        response = await self.send_single_cmd(
+            cmd=cnet.Commands.RemoveNetwork(networkID=_PDC_SSID_1), endpoint=endpoint)
+        self.assert_network_config_success(response, f"RemoveNetwork for {_PDC_SSID_1!r}")
+
+        self.step(10, "TH sends QueryIdentity with the KeyIdentifier field set to NCI_A_ID and the PossessionNonce "
+                      "field set to Nonce_1.",
+                  expectation="DUT sends a QueryIdentityResponse with Identity NCI_A and a valid PossessionSignature, "
+                              "i.e. the Network Client Identity is retained as long as it is referenced by the network "
+                              "configuration for PDC_SSID_2.")
+        response = await self.query_identity(endpoint, nci_a_id, nonce_1)
+        asserts.assert_equal(response.identity, nci_a,
+                             "QueryIdentity did not resolve NCI_A_ID to NCI_A after the network configuration for "
+                             "PDC_SSID_1 was removed, although the one for PDC_SSID_2 still refers to it.")
+        self.assert_valid_possession_signature(nci_a, nonce_1, response.possessionSignature,
+                                               "QueryIdentityResponse.PossessionSignature")
+
+        self.step(11, "TH sends QueryIdentity with the KeyIdentifier field set to NI_1_ID and the PossessionNonce "
+                      "field absent.",
+                  expectation="DUT responds with NOT_FOUND, since the only network configuration referring to that "
+                              "Network Identity was removed.")
+        await self.expect_status(cnet.Commands.QueryIdentity(keyIdentifier=ni_1_id), endpoint, Status.NotFound,
+                                 "QueryIdentity for the Network Identity of a removed network configuration")
+
+        self.step(12, "TH sends ArmFailSafe to the DUT with ExpiryLengthSeconds set to 0, forcibly disarming the "
+                      "fail-safe so that the configuration changes made by this test case are reverted, or waits for "
+                      "the fail-safe timer armed in step 3 to expire if it cannot reach the DUT.",
+                  expectation="If an ArmFailSafeResponse arrives, it reports no error.")
+        await self.disarm_failsafe()
+
+        self.step(13, "TH re-discovers the DUT if it has become unreachable, then reads the Networks attribute.",
+                  expectation="The list is equal to InitialNetworks.")
+        await self.wait_until_reachable()
+        networks = await self.read_networks(endpoint)
+        asserts.assert_equal(networks, initial_networks,
+                             "Disarming the fail-safe did not restore the Networks attribute to its original contents.")
+
+
+if __name__ == "__main__":
+    default_matter_test_main()
diff --git a/src/python_testing/TC_CNET_4_30.py b/src/python_testing/TC_CNET_4_30.py
new file mode 100644
index 0000000..222a1e7
--- /dev/null
+++ b/src/python_testing/TC_CNET_4_30.py
@@ -0,0 +1,292 @@
+#
+#    Copyright (c) 2026 Project CHIP Authors
+#    All rights reserved.
+#
+#    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.
+#
+
+# The DUT is a Matter commissioner that originates the commands under test, so this test
+# is driven by an operator working the DUT's own interface and is not run in CI.
+#
+# The TH server is the all-devices-app app with a Wi-Fi Network Commissioning cluster that
+# supports Per-Device Credentials and an emulated Wi-Fi driver (--wifi-emulated). The DUT
+# reaches the TH server over the network the TH is on.:
+#
+#   python3 src/python_testing/TC_CNET_4_30.py \
+#       --storage-path admin_storage.json \
+#       --PICS <the DUT's PICS file> \
+#       --string-arg th_server_app_path:out/linux-x64-all-devices/all-devices-app
+
+import asyncio
+import logging
+import os
+import random
+import re
+import tempfile
+import threading
+import time
+
+from mobly import asserts
+
+import matter.clusters as Clusters
+from matter import ChipDeviceCtrl
+from matter.clusters.Types import NullValue
+from matter.testing.apps import AppServerSubprocess
+from matter.testing.commissioning import SetupParameters
+from matter.testing.decorators import async_test_body, pics
+from matter.testing.event_attribute_reporting import AttributeSubscriptionHandler
+from matter.testing.matter_testing import MatterTestCommissioner
+from matter.testing.runner import default_matter_test_main
+
+log = logging.getLogger(__name__)
+
+cnet = Clusters.NetworkCommissioning
+cgen = Clusters.GeneralCommissioning
+
+# Identity of the harness-owned fabric used to observe the TH server.
+_HARNESS_VENDOR_ID = 0xFFF1
+_HARNESS_FABRIC_ID = 2
+_HARNESS_CONTROLLER_NODE_ID = 112233
+_TH_SERVER_NODE_ID = 1111
+
+# The DUT is driven by a human operator, so the window has to stay open long enough for
+# them to work through the vendor's commissioning UX.
+_COMMISSIONING_WINDOW_TIMEOUT_SECONDS = 900
+_COMMISSIONING_WINDOW_ITERATIONS = 10000
+
+# How long to wait for the TH server's record of a command after the operator says the DUT has
+# sent it.
+_EVIDENCE_TIMEOUT_SECONDS = 30
+_POLL_INTERVAL_SECONDS = 0.5
+
+# Logged by the interaction model for every command the TH server dispatches to a cluster.
+_RECEIVED_COMMAND_PATTERN = re.compile(
+    rb"Received command for Endpoint=(\d+) Cluster=0x([0-9A-F]{4})_([0-9A-F]{4}) "
+    rb"Command=0x([0-9A-F]{4})_([0-9A-F]{4})")
+
+_PDC_FEATURES = cnet.Bitmaps.Feature.kWiFiNetworkInterface | cnet.Bitmaps.Feature.kPerDeviceCredentials
+
+
+class TC_CNET_4_30(MatterTestCommissioner):
+
+    # The harness never opens a Matter session to the DUT: the DUT originates every command in
+    # this test and is driven through its own interface, while all of the harness's traffic
+    # goes to the TH server.
+    requires_dut = False
+
+    @property
+    def default_timeout(self) -> int:
+        # Every step waits on a human driving the DUT's commissioning and command UX.
+        return 3600
+
+    @async_test_body
+    async def setup_class(self):
+        super().setup_class()
+
+        self.th_server = None
+        self.storage = None
+        self.networks_subscription = None
+        # (endpoint, cluster, command) of each command the TH server has received, in order.
+        self._received_commands: list[tuple[int, int, int]] = []
+        self._received_commands_lock = threading.Lock()
+
+        th_server_app = self.user_params.get("th_server_app_path", None)
+        if not th_server_app:
+            asserts.fail("This test requires a TH server app with a Wi-Fi Network Commissioning cluster that supports "
+                         "Per-Device Credentials. Specify the path with --string-arg th_server_app_path:<path_to_app>")
+        if not os.path.exists(th_server_app):
+            asserts.fail(f"The path {th_server_app} does not exist")
+
+        self.storage = tempfile.TemporaryDirectory(prefix=self.__class__.__name__)
+        log.info("Temporary storage directory: %s", self.storage.name)
+
+        self.th_server_setup_params = SetupParameters(
+            discriminator=random.randint(0, 4095),
+            passcode=20202021)
+        self.th_server = AppServerSubprocess(
+            th_server_app,
+            storage_dir=self.storage.name,
+            port=self.user_params.get("th_server_port", 5543),
+            discriminator=self.th_server_setup_params.discriminator,
+            passcode=self.th_server_setup_params.passcode,
+            extra_args=["--wifi-emulated"])
+        forward_output = self.th_server.output_cb
+        self.th_server.output_cb = lambda line, is_stderr: forward_output(self._record_command(line), is_stderr)
+        self.th_server.start(
+            expected_output="Server initialization complete",
+            timeout=30)
+
+        certificate_authority = self.certificate_authority_manager.NewCertificateAuthority()
+        fabric_admin = certificate_authority.NewFabricAdmin(vendorId=_HARNESS_VENDOR_ID, fabricId=_HARNESS_FABRIC_ID)
+        self.th_server_controller = fabric_admin.NewController(
+            nodeId=_HARNESS_CONTROLLER_NODE_ID,
+            paaTrustStorePath=str(self.matter_test_config.paa_trust_store_path))
+        await self.th_server_controller.CommissionOnNetwork(
+            nodeId=_TH_SERVER_NODE_ID,
+            setupPinCode=self.th_server_setup_params.passcode,
+            filterType=ChipDeviceCtrl.DiscoveryFilterType.LONG_DISCRIMINATOR,
+            filter=self.th_server_setup_params.discriminator)
+        log.info("Commissioned the TH server onto the harness fabric")
+
+    def teardown_class(self):
+        if self.networks_subscription is not None:
+            self.networks_subscription.cancel()
+        if self.th_server is not None:
+            self.th_server.terminate()
+        if self.storage is not None:
+            self.storage.cleanup()
+        super().teardown_class()
+
+    def _record_command(self, line: bytes) -> bytes:
+        if match := _RECEIVED_COMMAND_PATTERN.search(line):
+            endpoint = int(match[1])
+            cluster = int(match[2] + match[3], 16)
+            command = int(match[4] + match[5], 16)
+            with self._received_commands_lock:
+                self._received_commands.append((endpoint, cluster, command))
+        return line
+
+    def _forget_received_commands(self) -> None:
+        with self._received_commands_lock:
+            self._received_commands.clear()
+
+    async def _find_wifi_pdc_endpoint(self) -> int:
+        """Returns the TH server endpoint hosting a Network Commissioning cluster with Wi-Fi and PDC."""
+        read = await self.th_server_controller.ReadAttribute(_TH_SERVER_NODE_ID, [cnet.Attributes.FeatureMap])
+        endpoints = [endpoint for endpoint, clusters in read.items()
+                     if clusters[cnet][cnet.Attributes.FeatureMap] & _PDC_FEATURES == _PDC_FEATURES]
+        asserts.assert_true(endpoints,
+                            "The TH server app does not host a Network Commissioning cluster with the WI and PDC "
+                            "features. Build it with chip_device_config_enable_wifi_pdc=true.")
+        return endpoints[0]
+
+    async def _ask_dut_to_commission_th_server(self) -> None:
+        params = await self.th_server_controller.OpenCommissioningWindow(
+            nodeId=_TH_SERVER_NODE_ID,
+            timeout=_COMMISSIONING_WINDOW_TIMEOUT_SECONDS,
+            iteration=_COMMISSIONING_WINDOW_ITERATIONS,
+            discriminator=random.randint(0, 4095),
+            option=self.th_server_controller.CommissioningWindowPasscode.kTokenWithRandomPin)
+
+        self.wait_for_user_input(
+            prompt_msg="Using the DUT's commissioning interface, commission the TH server using the following "
+                       "parameters:\n"
+                       f"- setupPinCode:   {params.setupPinCode}\n"
+                       f"- setupManualCode: {params.setupManualCode}\n"
+                       f"- setupQRCode:     {params.setupQRCode}\n"
+                       "\nPress enter once the DUT reports that commissioning completed.\n")
+
+        read = await self.th_server_controller.ReadAttribute(
+            _TH_SERVER_NODE_ID, [(0, Clusters.OperationalCredentials.Attributes.CommissionedFabrics)])
+        fabrics = read[0][Clusters.OperationalCredentials][Clusters.OperationalCredentials.Attributes.CommissionedFabrics]
+        asserts.assert_equal(fabrics, 2, "The TH server does not have a fabric for the DUT, so the DUT did not "
+                                         "commission it.")
+
+    async def _ask_dut_to_send(self, instruction: str, endpoint: int,
+                               command: type[Clusters.ClusterObjects.ClusterCommand]) -> None:
+        """Has the operator send a command from the DUT, then checks that the TH server received it.
+
+        A command the DUT sent earlier, e.g. as part of one UI action that covers several steps,
+        also counts, so the operator is only prompted if the TH server has not received it yet.
+        """
+        expected = (endpoint, command.cluster_id, command.command_id)
+        name = command.__name__
+        with self._received_commands_lock:
+            already_received = expected in self._received_commands
+        if not already_received:
+            self.wait_for_user_input(prompt_msg=f"{instruction}\n\nPress enter once the DUT has sent the command.\n")
+
+        deadline = time.monotonic() + _EVIDENCE_TIMEOUT_SECONDS
+        while True:
+            with self._received_commands_lock:
+                if expected in self._received_commands:
+                    return
+            if time.monotonic() > deadline:
+                asserts.fail(f"The TH server did not receive a {name} command on endpoint {endpoint}.")
+            await asyncio.sleep(_POLL_INTERVAL_SECONDS)
+
+    async def _wait_for_pdc_network(self, initial_networks: list[cnet.Structs.NetworkInfoStruct]) -> None:
+        """Waits for the TH server to report a network configuration added for Per-Device Credentials.
+
+        The TH server only accepts AddOrUpdateWiFiNetwork with a NetworkIdentity if the
+        Credentials field is empty, so a new entry with a NetworkIdentifier shows that the DUT's
+        command had both properties. All reports since the precondition are checked rather than
+        the current value, because the DUT may already have let the fail-safe expire.
+        """
+        deadline = time.monotonic() + _EVIDENCE_TIMEOUT_SECONDS
+        while True:
+            for report in self.networks_subscription.attribute_reports[cnet.Attributes.Networks]:
+                added = [n for n in report.value if n not in initial_networks and n.networkIdentifier is not None
+                         and n.networkIdentifier is not NullValue]
+                if added:
+                    log.info("The DUT added a PDC network configuration for %r", added[0].networkID)
+                    return
+            if time.monotonic() > deadline:
+                asserts.fail("The TH server did not report a network configuration that uses Per-Device Credentials, "
+                             "so the AddOrUpdateWiFiNetwork command did not have the NetworkIdentity field present "
+                             "and the Credentials field set to an empty octet string.")
+            await asyncio.sleep(_POLL_INTERVAL_SECONDS)
+
+    @pics("CNET.C.F00", "CNET.C.F03")
+    @async_test_body
+    async def test_TC_CNET_4_30(self):
+        """[TC-CNET-4.30] [Wi-Fi] Verification for Per-Device Credentials commands check [DUT-Client]"""
+
+        self.step("precondition",
+                  "TH has a Network Commissioning cluster on endpoint PIXIT.CNET.ENDPOINT_WIFI with WI and PDC set in "
+                  "its FeatureMap attribute, and the DUT can communicate with the TH.",
+                  expectation="The DUT commissions the TH server.")
+        endpoint = await self._find_wifi_pdc_endpoint()
+        log.info("TH server hosts a Wi-Fi PDC Network Commissioning cluster on endpoint %d", endpoint)
+        await self._ask_dut_to_commission_th_server()
+        initial_networks = await self.th_server_controller.ReadAttribute(
+            _TH_SERVER_NODE_ID, [(endpoint, cnet.Attributes.Networks)])
+        initial_networks = initial_networks[endpoint][cnet][cnet.Attributes.Networks]
+        self.networks_subscription = AttributeSubscriptionHandler(cnet, cnet.Attributes.Networks)
+        await self.networks_subscription.start(self.th_server_controller, _TH_SERVER_NODE_ID, endpoint)
+        self._forget_received_commands()
+
+        self.step(1, "DUT sends the ArmFailSafe command to the TH.",
+                  expectation="TH receives the ArmFailSafe command from the DUT.")
+        await self._ask_dut_to_send(
+            "Using the DUT's interface, send an ArmFailSafe command to the TH server.",
+            0, cgen.Commands.ArmFailSafe)
+
+        self.step(2, "DUT sends the AddOrUpdateWiFiNetwork command to the TH to configure a network that uses "
+                     "Per-Device Credentials.",
+                  expectation="TH receives the AddOrUpdateWiFiNetwork command from the DUT with the NetworkIdentity "
+                              "field present and the Credentials field set to an empty octet string.")
+        if self.pics_guard(self.check_pics("CNET.C.C02.Tx")):
+            await self._ask_dut_to_send(
+                f"Using the DUT's interface, send an AddOrUpdateWiFiNetwork command to endpoint {endpoint} of the TH "
+                "server that configures a Wi-Fi network using Per-Device Credentials.",
+                endpoint, cnet.Commands.AddOrUpdateWiFiNetwork)
+            await self._wait_for_pdc_network(initial_networks)
+
+        self.step(3, "DUT sends the QueryIdentity command to the TH.",
+                  expectation="TH receives the QueryIdentity command from the DUT.")
+        if self.pics_guard(self.check_pics("CNET.C.C09.Tx")):
+            await self._ask_dut_to_send(
+                f"Using the DUT's interface, send a QueryIdentity command to endpoint {endpoint} of the TH server.",
+                endpoint, cnet.Commands.QueryIdentity)
+
+        self.step(4, "DUT sends the ConnectNetwork command to the TH.",
+                  expectation="TH receives the ConnectNetwork command from the DUT.")
+        if self.pics_guard(self.check_pics("CNET.C.C06.Tx")):
+            await self._ask_dut_to_send(
+                f"Using the DUT's interface, send a ConnectNetwork command to endpoint {endpoint} of the TH server.",
+                endpoint, cnet.Commands.ConnectNetwork)
+
+
+if __name__ == "__main__":
+    default_matter_test_main()
diff --git a/src/python_testing/support_modules/wifi_fixture.py b/src/python_testing/support_modules/wifi_fixture.py
index 7a40f3e..0a1d12e 100644
--- a/src/python_testing/support_modules/wifi_fixture.py
+++ b/src/python_testing/support_modules/wifi_fixture.py
@@ -201,6 +201,15 @@
     return Station(address=reply.splitlines()[0], properties=_parse_properties(reply))
 
 
+def _file_identity(file: Path | int) -> tuple[int, int] | None:
+    """Returns what tells a file, given by path or open descriptor, apart from other files."""
+    try:
+        status = os.stat(file)
+    except FileNotFoundError:
+        return None
+    return status.st_dev, status.st_ino
+
+
 class ControlConnection:
     """A connection to one hostap control socket.
 
@@ -378,10 +387,7 @@
         """
         cls.verify()
         for daemon in daemons or (HOSTAPD, WPA_SUPPLICANT):
-            log.info("Restarting %s of the Wi-Fi fixture", daemon)
-            with cls._open(daemon) as control:
-                control.request_ok("TERMINATE")
-            cls._wait_for_restart(daemon)
+            cls._restart(daemon)
 
     @classmethod
     def reset_if_used(cls) -> None:
@@ -416,25 +422,34 @@
             raise WiFiFixtureError(f"The {daemon} of the Wi-Fi fixture answered a ping with '{reply}'")
 
     @classmethod
-    def _wait_for_restart(cls, daemon: str, timeout: float = RESTART_TIMEOUT) -> None:
+    def _restart(cls, daemon: str, timeout: float = RESTART_TIMEOUT) -> None:
+        log.info("Restarting %s of the Wi-Fi fixture", daemon)
         socket_path = cls.global_socket(daemon)
-        deadline = time.monotonic() + timeout
-        # The supervisor deletes the directory before starting the daemon again, so the
-        # socket going away is what tells the new daemon apart from the one being stopped.
-        while socket_path.exists():
-            if time.monotonic() > deadline:
-                raise WiFiFixtureError(f"The {daemon} of the Wi-Fi fixture did not stop within {timeout} seconds")
-            time.sleep(POLL_INTERVAL)
-        while True:
-            if socket_path.exists():
-                try:
-                    cls._ping(daemon)
-                    return
-                except WiFiFixtureError:
-                    pass  # Started, but not listening yet.
-            if time.monotonic() > deadline:
-                raise WiFiFixtureError(f"The {daemon} of the Wi-Fi fixture did not come back within {timeout} seconds")
-            time.sleep(POLL_INTERVAL)
+        # The supervisor recreates the directory before starting the daemon again, so a
+        # socket that is a different file from this one belongs to the new daemon. Waiting
+        # for the old one to go away instead would miss a restart that falls between two
+        # polls, and the restart can be over before the reply to TERMINATE arrives.
+        # Holding the old socket open keeps its identity from being reused for the new one.
+        stopping_socket = os.open(socket_path, os.O_PATH)
+        try:
+            stopping = _file_identity(stopping_socket)
+            with cls._open(daemon) as control:
+                control.request_ok("TERMINATE")
+            deadline = time.monotonic() + timeout
+            while True:
+                identity = _file_identity(socket_path)
+                if identity is not None and identity != stopping:
+                    try:
+                        cls._ping(daemon)
+                        return
+                    except WiFiFixtureError:
+                        pass  # Started, but not listening yet.
+                if time.monotonic() > deadline:
+                    action = "stop" if identity == stopping else "come back"
+                    raise WiFiFixtureError(f"The {daemon} of the Wi-Fi fixture did not {action} within {timeout} seconds")
+                time.sleep(POLL_INTERVAL)
+        finally:
+            os.close(stopping_socket)
 
     @classmethod
     def _read_interfaces(cls) -> tuple[Radio, ...]:
diff --git a/src/python_testing/test_metadata.yaml b/src/python_testing/test_metadata.yaml
index c03cb54..d87ecec 100644
--- a/src/python_testing/test_metadata.yaml
+++ b/src/python_testing/test_metadata.yaml
@@ -79,8 +79,16 @@
       reason:
           Requires a DUT supporting the Wi-Fi PDC feature, which CI does not
           provide
+    - name: TC_CNET_4_28.py
+      reason:
+          Requires a DUT supporting the Wi-Fi PDC feature, which CI does not
+          provide
     - name: TC_CNET_4_29.py
       reason: Can not run WiFi connection CNET tests in CI
+    - name: TC_CNET_4_30.py
+      reason:
+          The DUT is a commissioner that sends the commands under test, driven
+          by an operator
     - name: TC_DGGEN_3_2.py
       reason:
           src/python_testing/test_testing/test_TC_DGGEN_3_2.py is the Unit test