diff --git a/.clang-format b/.clang-format
index 4fb95466896aeb..a1c292ac4afefa 100644
--- a/.clang-format
+++ b/.clang-format
@@ -106,7 +106,6 @@ SpacesInSquareBrackets: false
 Standard:        Cpp11
 TabWidth:        8
 UseTab:          Never
-InsertNewlineAtEOF: true
 ---
 Language: ObjC
 BasedOnStyle: WebKit
diff --git a/.github/workflows/qemu.yaml b/.github/workflows/qemu.yaml
index 7c8564958d1350..9156d022e5667b 100644
--- a/.github/workflows/qemu.yaml
+++ b/.github/workflows/qemu.yaml
@@ -97,3 +97,68 @@ jobs:
                         --target tizen-arm-tests-no-ble-no-thread \
                         build
                     "
+
+    qemu-linux:
+        name: ubuntu
+
+        runs-on: ubuntu-latest
+        if: github.actor != 'restyled-io[bot]'
+
+        container:
+            image: ghcr.io/project-chip/chip-build-linux-qemu:74
+            volumes:
+                - "/tmp/log_output:/tmp/test_logs"
+            # Required for using KVM
+            options: --privileged
+
+        steps:
+            - name: Checkout
+              uses: actions/checkout@v4
+            - name: Checkout submodules & Bootstrap
+              uses: ./.github/actions/checkout-submodules-and-bootstrap
+              with:
+                platform: linux
+
+            - name: Build Apps
+              run: |
+                  scripts/run_in_build_env.sh './scripts/build_python.sh --install_virtual_env out/venv'
+                  ./scripts/run_in_build_env.sh \
+                     "./scripts/build/build_examples.py \
+                        --target linux-x64-chip-tool \
+                        --target linux-x64-all-clusters \
+                        build \
+                        --copy-artifacts-to objdir-clone \
+                     "
+            # There is no enough space for running the test withouth cleaning the environment
+            - name: Clean up
+              run: |
+                rm -rf out/*/obj
+                rm -rf out/*/lib
+                rm -rf out/*/*.map
+                rm -rf $PW_ENVIRONMENT_ROOT
+                git clean -fdx --exclude out
+            # Without all required apps paths provided as argument, script starts to search for them in the current directory and it takes a lot of time.
+            - name: Run ble commission test using the python parser sending commands to chip-tool
+              run: |
+                ./scripts/run_in_vm.sh \
+                "scripts/run_in_build_env.sh 'pip3 install -r scripts/setup/requirements.ble-wifi-testing.txt' && \
+                ./scripts/run_in_build_env.sh \
+                \"./scripts/tests/run_test_suite.py \
+                  --runner chip_tool_python \
+                  --target TestCommissionerNodeId \
+                  --chip-tool ./out/linux-x64-chip-tool/chip-tool \
+                  run \
+                    --iterations 1 \
+                    --test-timeout-seconds 120 \
+                    --all-clusters-app ./out/linux-x64-all-clusters/chip-all-clusters-app \
+                    --lock-app ./out/linux-x64-lock/chip-lock-app \
+                    --ota-provider-app ./out/linux-x64-ota-provider/chip-ota-provider-app \
+                    --ota-requestor-app ./out/linux-x64-ota-requestor/chip-ota-requestor-app \
+                    --tv-app ./out/linux-x64-tv-app/chip-tv-app \
+                    --bridge-app ./out/linux-x64-bridge/chip-bridge-app \
+                    --lit-icd-app ./out/linux-x64-lit-icd/lit-icd-app \
+                    --microwave-oven-app .out/linux-x64-microwave-oven/chip-microwave-oven-app  \
+                    --rvc-app .out/linux-x64-rvc/chip-rvc-app  \
+                    --ble-wifi \
+                \" \
+                "
diff --git a/.gitignore b/.gitignore
index 1c2d1430263594..e0c11483daa7aa 100644
--- a/.gitignore
+++ b/.gitignore
@@ -85,3 +85,5 @@ examples/*/esp32/dependencies.lock
 # jupyter temporary files
 .ipynb_checkpoints
 
+/runner.sh
+/runner_status
diff --git a/integrations/docker/images/stage-2/chip-build-linux-qemu/Dockerfile b/integrations/docker/images/stage-2/chip-build-linux-qemu/Dockerfile
index 02a65e01a7a829..3e68901772b898 100644
--- a/integrations/docker/images/stage-2/chip-build-linux-qemu/Dockerfile
+++ b/integrations/docker/images/stage-2/chip-build-linux-qemu/Dockerfile
@@ -124,7 +124,7 @@ RUN mkdir -p /tmp/workdir/linux \
 	# Download Ubuntu image for QEMU
 	&& curl https://cloud-images.ubuntu.com/minimal/releases/noble/release/ubuntu-24.04-minimal-cloudimg-amd64.img \
 	-o /tmp/workdir/ubuntu-24.04-minimal-cloudimg-amd64.img
-	# Prepare ubuntu image
+# Prepare ubuntu image
 RUN qemu-img create -f qcow2 -o preallocation=off $UBUNTU_QEMU_IMG 10G \
 	&& virt-resize --expand /dev/sda1 /tmp/workdir/ubuntu-24.04-minimal-cloudimg-amd64.img $UBUNTU_QEMU_IMG \
 	&& guestfish -a $UBUNTU_QEMU_IMG \
@@ -209,13 +209,14 @@ RUN qemu-img create -f qcow2 -o preallocation=off $UBUNTU_QEMU_IMG 10G \
 	-append 'console=ttyS0 root=/dev/vda4' \
 	-netdev user,id=network0 \
 	-device e1000,netdev=network0,mac=52:54:00:12:34:56 \
-    -virtfs "local,path=/tmp,mount_tag=host0,security_model=passthrough,id=host0" \
-# tmp folder is mounted only to preserve error during boot
+	-virtfs "local,path=/tmp,mount_tag=host0,security_model=passthrough,id=host0" \
+	# tmp folder is mounted only to preserve error during boot
 	&& mkdir -p /chip \
 	&& rm -rf /opt/ubuntu-qemu/rootfs \
 	&& echo -n \
 	"#!/bin/bash\n" \
 	"grep -q 'rootshell' /proc/cmdline && exit\n" \
+	"[[ -n \$SSH_CONNECTION ]] && exit \n" \
 	"if [[ -x /chip/runner.sh ]]; then\n" \
 	"  echo '### RUNNER START ###'\n" \
 	"  cd /chip\n" \
@@ -227,7 +228,7 @@ RUN qemu-img create -f qcow2 -o preallocation=off $UBUNTU_QEMU_IMG 10G \
 	"  read -r -t 5 -p 'Press ENTER to access root shell...' && exit || echo ' timeout.'\n" \
 	"fi\n" \
 	"echo 'Shutting down emulated system...'\n" \
-	"echo o > /proc/sysrq-trigger\n" \
+	"systemctl poweroff\n" \
 	| guestfish --rw -a $UBUNTU_QEMU_IMG -m /dev/sda4:/ upload - /launcher.sh : chmod 0755 /launcher.sh \
 	&& virt-sparsify --compress ${UBUNTU_QEMU_IMG} ${UBUNTU_QEMU_IMG}.compressed \
 	&& mv ${UBUNTU_QEMU_IMG}.compressed ${UBUNTU_QEMU_IMG} \
diff --git a/integrations/docker/images/stage-2/chip-build-linux-qemu/run-img.sh b/integrations/docker/images/stage-2/chip-build-linux-qemu/run-img.sh
index 43cc4f80a9effa..e089f396680fb7 100755
--- a/integrations/docker/images/stage-2/chip-build-linux-qemu/run-img.sh
+++ b/integrations/docker/images/stage-2/chip-build-linux-qemu/run-img.sh
@@ -1,7 +1,7 @@
 #!/bin/bash
 
 KERNEL="/opt/ubuntu-qemu/bzImage"
-IMG="/opt/ubuntu-qemu/ubuntu-20.04.img"
+IMG="/opt/ubuntu-qemu/ubuntu-24.04.img"
 ADDITIONAL_ARGS=()
 PROJECT_PATH="$(realpath "$(dirname "$0")/../../../../..")"
 
@@ -23,7 +23,7 @@ fi
     -device virtio-blk-pci,drive=virtio-blk1 \
     -drive file="$IMG",id=virtio-blk1,if=none,format=qcow2,readonly=off \
     -kernel "$KERNEL" \
-    -append 'console=ttyS0 mac80211_hwsim.radios=2 root=/dev/vda3' \
+    -append 'console=ttyS0 mac80211_hwsim.radios=2 root=/dev/vda4' \
     -netdev user,id=network0,hostfwd=tcp::2222-:22 \
     -device e1000,netdev=network0,mac=52:54:00:12:34:56 \
     -virtfs "local,path=$PROJECT_PATH,mount_tag=host0,security_model=passthrough,id=host0" \
diff --git a/scripts/py_matter_yamltests/matter_yamltests/pseudo_clusters/clusters/accessory_server_bridge.py b/scripts/py_matter_yamltests/matter_yamltests/pseudo_clusters/clusters/accessory_server_bridge.py
index 15ebdf46428830..c83f85323eaf60 100644
--- a/scripts/py_matter_yamltests/matter_yamltests/pseudo_clusters/clusters/accessory_server_bridge.py
+++ b/scripts/py_matter_yamltests/matter_yamltests/pseudo_clusters/clusters/accessory_server_bridge.py
@@ -22,7 +22,7 @@
 _PORT = 9000
 
 if sys.platform == 'linux':
-    _IP = '10.10.10.5'
+    _IP = "10.10.12.5"
 
 
 def _make_url():
diff --git a/scripts/run_in_vm.sh b/scripts/run_in_vm.sh
new file mode 100755
index 00000000000000..7050dc7611f145
--- /dev/null
+++ b/scripts/run_in_vm.sh
@@ -0,0 +1,36 @@
+#!/usr/bin/env bash
+#
+# Copyright (c) 2024 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.
+#
+
+# This script executes the command given as an argument after
+# activating the given python virtual environment
+set -e
+
+PROJECT_PATH=$(dirname "$(dirname "$(realpath "$0")")")
+
+echo "$@" >"$PROJECT_PATH/runner.sh"
+chmod +x "$PROJECT_PATH/runner.sh"
+
+echo "CMD:"
+cat "$PROJECT_PATH/runner.sh"
+
+"$PROJECT_PATH/integrations/docker/images/stage-2/chip-build-linux-qemu/run-img.sh"
+
+if [ -f "$PROJECT_PATH/runner_status" ]; then
+    exit "$(cat "$PROJECT_PATH/runner_status")"
+else
+    exit 1
+fi
diff --git a/scripts/setup/requirements.ble-wifi-testing.txt b/scripts/setup/requirements.ble-wifi-testing.txt
new file mode 100644
index 00000000000000..69c66f7cd4c8de
--- /dev/null
+++ b/scripts/setup/requirements.ble-wifi-testing.txt
@@ -0,0 +1 @@
+PyGObject
diff --git a/scripts/tests/chiptest/accessories.py b/scripts/tests/chiptest/accessories.py
index 99433d7ed88660..b35ba3a7e57c9b 100644
--- a/scripts/tests/chiptest/accessories.py
+++ b/scripts/tests/chiptest/accessories.py
@@ -28,7 +28,7 @@
 PORT = 9000
 
 if sys.platform == 'linux':
-    IP = '10.10.10.5'
+    IP = "10.10.12.5"
 
 
 class AppsRegister:
diff --git a/scripts/tests/chiptest/linux.py b/scripts/tests/chiptest/linux.py
index bf3e140981d3a7..0cb97ff40b2589 100644
--- a/scripts/tests/chiptest/linux.py
+++ b/scripts/tests/chiptest/linux.py
@@ -18,15 +18,22 @@
 Handles linux-specific functionality for running test cases
 """
 
+import glob
 import logging
 import os
+import shutil
 import subprocess
 import sys
 import time
+from collections import namedtuple
+from time import sleep
+from typing import Optional
 
 from .test_definition import ApplicationPaths
 
 test_environ = os.environ.copy()
+PW_PROJECT_ROOT = os.environ.get("PW_PROJECT_ROOT")
+QEMU_CONFIG_FILES = "integrations/docker/images/stage-2/chip-build-linux-qemu/files"
 
 
 def EnsureNetworkNamespaceAvailability():
@@ -57,7 +64,7 @@ def EnsurePrivateState():
         sys.exit(1)
 
 
-def CreateNamespacesForAppTest():
+def CreateNamespacesForAppTest(ble_wifi: bool = False):
     """
     Creates appropriate namespaces for a tool and app binaries in a simulated
     isolated network.
@@ -88,23 +95,33 @@ def CreateNamespacesForAppTest():
         "ip netns exec app ip link set dev lo up",
         "ip link set dev eth-app-switch up",
 
-        "ip netns exec tool ip addr add 10.10.10.2/24 dev eth-tool",
+        "ip netns exec tool ip addr add 10.10.12.2/24 dev eth-tool",
         "ip netns exec tool ip link set dev eth-tool up",
         "ip netns exec tool ip link set dev lo up",
         "ip link set dev eth-tool-switch up",
 
-        # Force IPv6 to use ULAs that we control
-        "ip netns exec tool ip -6 addr flush eth-tool",
-        "ip netns exec app ip -6 addr flush eth-app",
-        "ip netns exec tool ip -6 a add fd00:0:1:1::2/64 dev eth-tool",
-        "ip netns exec app ip -6 a add fd00:0:1:1::3/64 dev eth-app",
-
-        # create link between virtual host 'tool' and the test runner
         "ip addr add 10.10.10.5/24 dev eth-ci",
+        "ip addr add 10.10.12.5/24 dev eth-ci",
         "ip link set dev eth-ci up",
         "ip link set dev eth-ci-switch up",
     ]
 
+    if not ble_wifi:
+        COMMANDS += [
+            "ip link add eth-app-direct type veth peer name eth-tool-direct",
+            "ip link set eth-app-direct netns app",
+            "ip link set eth-tool-direct netns tool",
+            "ip netns exec app ip addr add 10.10.15.1/24 dev eth-app-direct",
+            "ip netns exec app ip link set dev eth-app-direct up",
+            "ip netns exec tool ip addr add 10.10.15.2/24 dev eth-tool-direct",
+            "ip netns exec tool ip link set dev eth-tool-direct up",
+            # Force IPv6 to use ULAs that we control
+            "ip netns exec tool ip -6 addr flush eth-tool",
+            "ip netns exec app ip -6 addr flush eth-app",
+            "ip netns exec tool ip -6 a add fd00:0:1:1::2/64 dev eth-tool-direct",
+            "ip netns exec app ip -6 a add fd00:0:1:1::3/64 dev eth-app-direct",
+        ]
+
     for command in COMMANDS:
         logging.debug("Executing '%s'" % command)
         if os.system(command) != 0:
@@ -156,19 +173,232 @@ def RemoveNamespaceForAppTest():
             sys.exit(1)
 
 
-def PrepareNamespacesForTestExecution(in_unshare: bool):
+def PrepareNamespacesForTestExecution(in_unshare: bool, ble_wifi: bool = False):
     if not in_unshare:
         EnsureNetworkNamespaceAvailability()
     elif in_unshare:
         EnsurePrivateState()
 
-    CreateNamespacesForAppTest()
+    CreateNamespacesForAppTest(ble_wifi)
 
 
 def ShutdownNamespaceForTestExecution():
     RemoveNamespaceForAppTest()
 
 
+class DbusTest:
+    DBUS_SYSTEM_BUS_ADDRESS = "unix:path=/tmp/chip-dbus-test"
+
+    def __init__(self, dry_run: bool = False):
+        self._dbus = None
+        self._dbus_proxy = None
+        self.dry_run = dry_run
+
+    def start(self):
+        if self.dry_run:
+            logging.info("Would start dbus")
+            return
+        original_env = os.environ.copy()
+        os.environ["DBUS_SYSTEM_BUS_ADDRESS"] = DbusTest.DBUS_SYSTEM_BUS_ADDRESS
+        dbus = shutil.which("dbus-daemon")
+        self._dbus = subprocess.Popen([dbus, "--session", "--address", self.DBUS_SYSTEM_BUS_ADDRESS])
+
+        self._dbus_proxy = subprocess.Popen(
+            ["python3", f"{PW_PROJECT_ROOT}/scripts/tools/dbus-proxy-bluez.py", "--bus-proxy", DbusTest.DBUS_SYSTEM_BUS_ADDRESS],
+            env=original_env,
+        )
+
+    def stop(self):
+        if self._dbus:
+            self._dbus_proxy.terminate()
+            self._dbus.terminate()
+            self._dbus.wait()
+
+
+class VirtualWifi:
+    def __init__(
+        self,
+        hostapd_path: str,
+        dnsmasq_path: str,
+        wpa_supplicant_path: str,
+        wlan_app: Optional[str] = None,
+        wlan_tool: Optional[str] = None,
+        dry_run: bool = False,
+    ):
+        self.dry_run = dry_run
+        self._hostapd_path = hostapd_path
+        self._dnsmasq_path = dnsmasq_path
+        self._wpa_supplicant_path = wpa_supplicant_path
+        self._hostapd_conf = os.path.join(PW_PROJECT_ROOT, QEMU_CONFIG_FILES, "wifi/hostapd.conf")
+        self._dnsmasq_conf = os.path.join(PW_PROJECT_ROOT, QEMU_CONFIG_FILES, "wifi/dnsmasq.conf")
+        self._wpa_supplicant_conf = os.path.join(PW_PROJECT_ROOT, QEMU_CONFIG_FILES, "wifi/wpa_supplicant.conf")
+
+        if (wlan_app is None or wlan_tool is None) and not dry_run:
+            wlans = glob.glob("/sys/devices/virtual/mac80211_hwsim/hwsim*/net/*")
+            if len(wlans) < 2:
+                raise RuntimeError("Not enough wlan devices found")
+
+            self._wlan_app = os.path.basename(wlans[0])
+            self._wlan_tool = os.path.basename(wlans[1])
+        else:
+            self._wlan_app = wlan_app
+            self._wlan_tool = wlan_tool
+        self._hostapd = None
+        self._dnsmasq = None
+        self._wpa_supplicant = None
+        self._dhclient = None
+
+    @staticmethod
+    def _get_phy(dev: str) -> str:
+        output = subprocess.check_output(["iw", "dev", dev, "info"])
+        for line in output.split(b"\n"):
+            if b"wiphy" in line:
+                wiphy = int(line.split(b" ")[1])
+                return f"phy{wiphy}"
+        raise ValueError(f"No wiphy found for {dev}")
+
+    @staticmethod
+    def _move_phy_to_netns(phy: str, netns: str):
+        subprocess.check_call(["iw", "phy", phy, "set", "netns", "name", netns])
+
+    @staticmethod
+    def _set_interface_ip_in_netns(netns: str, dev: str, ip: str):
+        subprocess.check_call(["ip", "netns", "exec", netns, "ip", "link", "set", "dev", dev, "up"])
+        subprocess.check_call(["ip", "netns", "exec", netns, "ip", "addr", "add", ip, "dev", dev])
+
+    def start(self):
+        hostapd_cmd = ["ip", "netns", "exec", "tool", self._hostapd_path, self._hostapd_conf]
+        dnsmaq_cmd = ["ip", "netns", "exec", "tool", self._dnsmasq_path, "-d", "-C", self._dnsmasq_conf]
+        dhclient_cmd = ["ip", "netns", "exec", "app", "dhclient", self._wlan_app]
+        wpa_cmd = [
+            "ip",
+            "netns",
+            "exec",
+            "app",
+            self._wpa_supplicant_path,
+            "-u",
+            "-s",
+            "-i",
+            self._wlan_app,
+            "-c",
+            self._wpa_supplicant_conf,
+        ]
+        if self.dry_run:
+            logging.info(f"Would run hostapd with {hostapd_cmd}")
+            logging.info(f"Would run dnsmasq with {dnsmaq_cmd}")
+            logging.info(f"Would run wpa_supplicant with {wpa_cmd}")
+            return
+        # Write clean configuration for wifi to prevent auto wifi connection during next test
+        with open(self._wpa_supplicant_conf, "w") as f:
+            f.write("ctrl_interface=DIR=/run/wpa_supplicant\nctrl_interface_group=root\nupdate_config=1\n")
+        self._move_phy_to_netns(self._get_phy(self._wlan_app), "app")
+        self._move_phy_to_netns(self._get_phy(self._wlan_tool), "tool")
+        self._set_interface_ip_in_netns("tool", self._wlan_tool, "192.168.200.1/24")
+
+        self._hostapd = subprocess.Popen(hostapd_cmd, stdout=subprocess.DEVNULL)
+        self._dnsmasq = subprocess.Popen(dnsmaq_cmd, stdout=subprocess.DEVNULL)
+        self._dhclient = subprocess.Popen(dhclient_cmd, stdout=subprocess.DEVNULL)
+        print(f"DnsMasq started with {self._dnsmasq.pid}")
+        self._wpa_supplicant = subprocess.Popen(wpa_cmd, stdout=subprocess.DEVNULL)
+
+    def stop(self):
+        if self.dry_run:
+            logging.info("Would stop hostapd, dnsmasq and wpa_supplicant")
+            return
+        if self._hostapd:
+            self._hostapd.terminate()
+            self._hostapd.wait()
+        if self._dnsmasq:
+            self._dnsmasq.terminate()
+            self._dnsmasq.wait()
+        if self._wpa_supplicant:
+            self._wpa_supplicant.terminate()
+            self._wpa_supplicant.wait()
+        if self._dhclient:
+            self._dhclient.terminate()
+            self._dhclient.wait()
+
+
+class VirtualBle:
+    BleDevice = namedtuple("BleDevice", ["hci", "mac", "index"])
+
+    def __init__(self, btvirt_path: str, bluetoothctl_path: str, dry_run: bool = False):
+        self._btvirt_path = btvirt_path
+        self._bluetoothctl_path = bluetoothctl_path
+        self._btvirt = None
+        self._bluetoothctl = None
+        self._ble_app = None
+        self._ble_tool = None
+        self._dry_run = dry_run
+
+    @property
+    def ble_app(self) -> Optional[BleDevice]:
+        if self._dry_run:
+            return self.BleDevice(hci="hci0", mac="00:11:22:33:44:55", index=0)
+        if not self._ble_app:
+            raise RuntimeError("Bluetooth not started")
+        return self._ble_app
+
+    @property
+    def ble_tool(self) -> Optional[BleDevice]:
+        if self._dry_run:
+            return self.BleDevice(hci="hci1", mac="00:11:22:33:44:56", index=1)
+        if not self._ble_tool:
+            raise RuntimeError("Bluetooth not started")
+        return self._ble_tool
+
+    def bletoothctl_cmd(self, cmd):
+        self._bluetoothctl.stdin.write(cmd)
+        self._bluetoothctl.stdin.flush()
+
+    def _get_mac_address(self, hci_name):
+        result = subprocess.run(["hcitool", "dev"], capture_output=True, text=True)
+        lines = result.stdout.splitlines()
+
+        for line in lines:
+            if hci_name in line:
+                mac_address = line.split()[1]
+                return mac_address
+
+        raise RuntimeError(f"No MAC address found for device {hci_name}")
+
+    def _get_ble_info(self):
+        ble_dev_paths = glob.glob("/sys/devices/virtual/bluetooth/hci*")
+        hci = [os.path.basename(path) for path in ble_dev_paths]
+        if len(hci) < 2:
+            raise RuntimeError("Not enough BLE devices found")
+        self._ble_app = self.BleDevice(hci=hci[0], mac=self._get_mac_address(hci[0]), index=int(hci[0].replace("hci", "")))
+        self._ble_tool = self.BleDevice(hci=hci[1], mac=self._get_mac_address(hci[1]), index=int(hci[1].replace("hci", "")))
+
+    def _run_bluetoothctl(self):
+        self._bluetoothctl = subprocess.Popen(
+            [self._bluetoothctl_path], text=True, stdin=subprocess.PIPE, stdout=subprocess.DEVNULL
+        )
+        self.bletoothctl_cmd(f"select {self.ble_app.mac}\n")
+        self.bletoothctl_cmd("power on\n")
+        self.bletoothctl_cmd(f"select {self.ble_tool.mac}\n")
+        self.bletoothctl_cmd("power on\n")
+        self.bletoothctl_cmd("quit\n")
+        self._bluetoothctl.wait()
+
+    def start(self):
+        if self._dry_run:
+            logging.info("Would start bluetooth")
+            return
+        self._btvirt = subprocess.Popen([self._btvirt_path, "-l2"])
+        sleep(1)
+        self._get_ble_info()
+        self._run_bluetoothctl()
+
+    def stop(self):
+        if self._dry_run:
+            logging.info("Would stop bluetooth")
+            return
+        if self._btvirt:
+            self._btvirt.terminate()
+            self._btvirt.wait()
+
+
 def PathsWithNetworkNamespaces(paths: ApplicationPaths) -> ApplicationPaths:
     """
     Returns a copy of paths with updated command arrays to invoke the
diff --git a/scripts/tests/chiptest/test_definition.py b/scripts/tests/chiptest/test_definition.py
index 484f247f70b1b2..3689a152ed7491 100644
--- a/scripts/tests/chiptest/test_definition.py
+++ b/scripts/tests/chiptest/test_definition.py
@@ -291,7 +291,7 @@ def tags_str(self) -> str:
         return ", ".join([t.to_s() for t in self.tags])
 
     def Run(self, runner, apps_register, paths: ApplicationPaths, pics_file: str,
-            timeout_seconds: typing.Optional[int], dry_run=False, test_runtime: TestRunTime = TestRunTime.CHIP_TOOL_PYTHON):
+            timeout_seconds: typing.Optional[int], dry_run=False, test_runtime: TestRunTime = TestRunTime.CHIP_TOOL_PYTHON, app_hci_number: typing.Optional[int] = None, tool_hci_number: typing.Optional[int] = None):
         """
         Executes the given test case using the provided runner for execution.
         """
@@ -342,8 +342,11 @@ def Run(self, runner, apps_register, paths: ApplicationPaths, pics_file: str,
                         key = 'default'
                     else:
                         key = os.path.basename(path[-1])
-
-                    app = App(runner, path)
+                    ble_wifi_cmd = []
+                    if app_hci_number is not None:
+                        ble_wifi_cmd = ["--ble-device",
+                                        str(app_hci_number), "--wifi"]
+                    app = App(runner, path + ble_wifi_cmd)
                     # Add the App to the register immediately, so if it fails during
                     # start() we will be able to clean things up properly.
                     apps_register.add(key, app)
@@ -383,13 +386,27 @@ def Run(self, runner, apps_register, paths: ApplicationPaths, pics_file: str,
                     runner.RunSubprocess(python_cmd, name='CHIP_REPL_YAML_TESTER',
                                          dependencies=[apps_register], timeout_seconds=timeout_seconds)
             else:
-                pairing_cmd = paths.chip_tool_with_python_cmd + ['pairing', 'code', TEST_NODE_ID, setupCode]
+                pairing_server_args = []
+                if tool_hci_number is not None:
+                    pairing_cmd = paths.chip_tool_with_python_cmd + [
+                        "pairing",
+                        "code-wifi",
+                        TEST_NODE_ID,
+                        "Virtual_Wifi",
+                        "ExamplePassword",
+                        "MT:-24J042C00KA0648G00",
+                    ]
+                    pairing_server_args = [
+                        "--ble-adapter", str(tool_hci_number)]
+                else:
+                    pairing_cmd = paths.chip_tool_with_python_cmd + ['pairing', 'code', TEST_NODE_ID, setupCode]
                 if self.target == TestTarget.LIT_ICD and test_runtime == TestRunTime.CHIP_TOOL_PYTHON:
                     pairing_cmd += ['--icd-registration', 'true']
                 test_cmd = paths.chip_tool_with_python_cmd + ['tests', self.run_name] + ['--PICS', pics_file]
                 server_args = ['--server_path', paths.chip_tool[-1]] + \
                     ['--server_arguments', 'interactive server' +
-                        (' ' if len(tool_storage_args) else '') + ' '.join(tool_storage_args)]
+                        (' ' if len(tool_storage_args) else '') + ' '.join(tool_storage_args) +
+                        (' ' if len(pairing_server_args) else '') + ' '.join(pairing_server_args)]
                 pairing_cmd += server_args
                 test_cmd += server_args
 
diff --git a/scripts/tests/run_test_suite.py b/scripts/tests/run_test_suite.py
index 41b7b540a6dd50..37c31bd80593bf 100755
--- a/scripts/tests/run_test_suite.py
+++ b/scripts/tests/run_test_suite.py
@@ -295,10 +295,16 @@ def cmd_list(context):
     default=0,
     show_default=True,
     help='Number of tests that are expected to fail in each iteration.  Overall test will pass if the number of failures matches this.  Nonzero values require --keep-going')
+@click.option(
+    '--ble-wifi',
+    is_flag=True,
+    default=False,
+    show_default=True,
+    help='Use a virtual wifi and bluetooth to commission device')
 @click.pass_context
 def cmd_run(context, iterations, all_clusters_app, lock_app, ota_provider_app, ota_requestor_app,
             fabric_bridge_app, tv_app, bridge_app, lit_icd_app, microwave_oven_app, rvc_app, network_manager_app, chip_repl_yaml_tester,
-            chip_tool_with_python, pics_file, keep_going, test_timeout_seconds, expected_failures):
+            chip_tool_with_python, pics_file, keep_going, test_timeout_seconds, expected_failures, ble_wifi):
     if expected_failures != 0 and not keep_going:
         logging.exception(f"'--expected-failures {expected_failures}' used without '--keep-going'")
         sys.exit(2)
@@ -368,8 +374,24 @@ def cmd_run(context, iterations, all_clusters_app, lock_app, ota_provider_app, o
     )
 
     if sys.platform == 'linux':
-        chiptest.linux.PrepareNamespacesForTestExecution(
-            context.obj.in_unshare)
+        chiptest.linux.PrepareNamespacesForTestExecution(context.obj.in_unshare, ble_wifi)
+        if ble_wifi:
+            dbus = chiptest.linux.DbusTest()
+            dbus.start()
+
+            virt_wifi = chiptest.linux.VirtualWifi(
+                "/usr/sbin/hostapd",
+                "/usr/sbin/dnsmasq",
+                "/usr/sbin/wpa_supplicant",
+                dry_run=context.obj.dry_run,
+            )
+            virt_ble = chiptest.linux.VirtualBle(
+                "/usr/bin/btvirt",
+                "/usr/bin/bluetoothctl",
+                dry_run=context.obj.dry_run,
+            )
+            virt_wifi.start()
+            virt_ble.start()
         paths = chiptest.linux.PathsWithNetworkNamespaces(paths)
 
     logging.info("Each test will be executed %d times" % iterations)
@@ -380,10 +402,14 @@ def cmd_run(context, iterations, all_clusters_app, lock_app, ota_provider_app, o
     def cleanup():
         apps_register.uninit()
         if sys.platform == 'linux':
+            if ble_wifi:
+                virt_wifi.stop()
+                virt_ble.stop()
+                dbus.stop()
             chiptest.linux.ShutdownNamespaceForTestExecution()
 
     for i in range(iterations):
-        logging.info("Starting iteration %d" % (i+1))
+        logging.info("Starting iteration %d" % (i + 1))
         observed_failures = 0
         for test in context.obj.tests:
             if context.obj.include_tags:
@@ -401,18 +427,35 @@ def cleanup():
                 if context.obj.dry_run:
                     logging.info("Would run test: %s" % test.name)
                 else:
-                    logging.info('%-20s - Starting test' % (test.name))
-                test.Run(
-                    runner, apps_register, paths, pics_file, test_timeout_seconds, context.obj.dry_run,
-                    test_runtime=context.obj.runtime)
+                    logging.info("%-20s - Starting test" % (test.name))
+                if ble_wifi:
+                    test.Run(
+                        runner,
+                        apps_register,
+                        paths,
+                        pics_file,
+                        test_timeout_seconds,
+                        context.obj.dry_run,
+                        test_runtime=context.obj.runtime,
+                        app_hci_number=virt_ble.ble_app.index,
+                        tool_hci_number=virt_ble.ble_tool.index,
+                    )
+                else:
+                    test.Run(
+                        runner,
+                        apps_register,
+                        paths,
+                        pics_file,
+                        test_timeout_seconds,
+                        context.obj.dry_run,
+                        test_runtime=context.obj.runtime,
+                    )
                 if not context.obj.dry_run:
                     test_end = time.monotonic()
-                    logging.info('%-30s - Completed in %0.2f seconds' %
-                                 (test.name, (test_end - test_start)))
+                    logging.info("%-30s - Completed in %0.2f seconds" % (test.name, (test_end - test_start)))
             except Exception:
                 test_end = time.monotonic()
-                logging.exception('%-30s - FAILED in %0.2f seconds' %
-                                  (test.name, (test_end - test_start)))
+                logging.exception("%-30s - FAILED in %0.2f seconds" % (test.name, (test_end - test_start)))
                 observed_failures += 1
                 if not keep_going:
                     cleanup()
diff --git a/scripts/tools/dbus-proxy-bluez.py b/scripts/tools/dbus-proxy-bluez.py
new file mode 100755
index 00000000000000..3ff0bda943b0f7
--- /dev/null
+++ b/scripts/tools/dbus-proxy-bluez.py
@@ -0,0 +1,232 @@
+#!/usr/bin/env python3
+#
+#  Copyright (c) 2024 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 logging
+import os.path
+import typing
+from argparse import ArgumentParser
+from collections import namedtuple
+
+from gi.repository import Gio, GLib
+
+
+def bus_get_connection(address: str):
+    """Get a connection object for a given D-Bus bus."""
+    if address == "session":
+        address = Gio.dbus_address_get_for_bus_sync(Gio.BusType.SESSION)
+    elif address == "system":
+        address = Gio.dbus_address_get_for_bus_sync(Gio.BusType.SYSTEM)
+    logging.info("Connecting to: %s", address)
+    conn = Gio.DBusConnection.new_for_address_sync(
+        address,
+        Gio.DBusConnectionFlags.AUTHENTICATION_CLIENT |
+        Gio.DBusConnectionFlags.MESSAGE_BUS_CONNECTION)
+    logging.info("Assigned unique name: %s", conn.get_unique_name())
+    return conn
+
+
+def bus_get_name_owner(conn, name: str):
+    """Get the unique name of a well known name on a D-Bus bus."""
+    params = GLib.Variant("(s)", (name,))
+    reply = conn.call_sync("org.freedesktop.DBus", "/org/freedesktop/DBus",
+                           "org.freedesktop.DBus", "GetNameOwner",
+                           params, None, Gio.DBusCallFlags.NONE, -1)
+    return reply.get_child_value(0).get_string()
+
+
+def bus_introspect_path(conn, client: str, path: str):
+    """Introspect a D-Bus object path and return its node info."""
+    reply = conn.call_sync(client, path,
+                           "org.freedesktop.DBus.Introspectable", "Introspect",
+                           None, None, Gio.DBusCallFlags.NONE, -1)
+    xml = reply.get_child_value(0).get_string()
+    return Gio.DBusNodeInfo.new_for_xml(xml)
+
+
+class DBusServiceProxy:
+
+    MappingKey = namedtuple("MappingKey", ["path", "iface"])
+
+    objects: typing.Dict[MappingKey, int] = {}
+    subscriptions: typing.Set[str] = set()
+    clients = {}
+
+    def __init__(self, source: str, proxy: str, service: str):
+        self.source = bus_get_connection(source)
+        self.proxy = bus_get_connection(proxy)
+        self.service = service
+        Gio.bus_own_name_on_connection(self.proxy, self.service,
+                                       Gio.BusNameOwnerFlags.DO_NOT_QUEUE,
+                                       self.on_bus_name_acquired,
+                                       self.on_bus_name_lost)
+
+    def on_bus_name_acquired(self, conn, name):
+        logging.info("Acquired name on proxy bus: %s", name)
+        self.mirror_source_on_proxy(self.service, "/")
+
+    def on_bus_name_lost(self, conn, name):
+        logging.debug("Lost name on proxy bus: %s", name)
+
+    def proxy_client_save(self, path, client):
+        self.clients[path] = client
+
+    def proxy_client_load(self, path):
+        return self.clients[path]
+
+    def register_object(self, conn, path, iface):
+        key = DBusServiceProxy.MappingKey(path, iface.name)
+        if key not in self.objects:
+            logging.debug("Registering: %s { %s }", path, iface.name)
+            id = conn.register_object(path, iface, self.on_method_call)
+            self.objects[key] = id
+
+    def unregister_object(self, conn, path, iface_name):
+        key = DBusServiceProxy.MappingKey(path, iface_name)
+        if key in self.objects:
+            logging.debug("Removing: %s { %s }", path, iface_name)
+            conn.unregister_object(self.objects.pop(key))
+
+    def signal_subscribe(self, conn, client):
+        """Subscribe for signals from a D-Bus client."""
+        if client not in self.subscriptions:
+            conn.signal_subscribe(client, None, None, None, None,
+                                  Gio.DBusSignalFlags.NONE,
+                                  self.on_signal_received)
+            self.subscriptions.add(client)
+
+    def mirror_path(self, conn_src, conn_dest, client, path, save=False):
+        """Mirror all interfaces and nodes of a D-Bus client object path.
+
+        Parameters:
+        conn_src -- source D-Bus connection
+        conn_dest -- proxy D-Bus connection
+        client -- name of the client on the source bus
+        path -- object path to mirror recursively
+        save -- save the client name for the path
+
+        """
+        info = bus_introspect_path(conn_src, client, path)
+        for iface in info.interfaces:
+            if save:
+                self.proxy_client_save(path, client)
+            self.register_object(conn_dest, path, iface)
+        for node in info.nodes:
+            self.mirror_path(conn_src, conn_dest, client,
+                             os.path.join(path, node.path), save)
+
+    def mirror_source_on_proxy(self, client, path):
+        """Mirror source bus objects on the proxy bus."""
+        self.signal_subscribe(self.source, client)
+        self.mirror_path(self.source, self.proxy, client, path)
+
+    def mirror_proxy_on_source(self, client, path):
+        """Mirror proxy bus objects on the source bus."""
+        self.signal_subscribe(self.proxy, client)
+        self.mirror_path(self.proxy, self.source, client, path, True)
+
+    def on_method_call(self, conn, sender, *args, **kwargs):
+        if conn == self.source:
+            return self.on_method_call_from_source(sender, *args, **kwargs)
+        return self.on_method_call_from_proxy(sender, *args, **kwargs)
+
+    def on_signal_received(self, conn, sender, *args, **kwargs):
+        if conn == self.source:
+            return self.on_signal_from_source(sender, *args, **kwargs)
+        return self.on_signal_from_proxy(sender, *args, **kwargs)
+
+    def on_method_call_from_source(self, sender, path, iface, method,
+                                   params, invocation):
+        logging.debug("Call from source: %s %s.%s()", path, iface, method)
+        self.proxy.call(self.proxy_client_load(path), path, iface, method,
+                        params, None, Gio.DBusCallFlags.NONE, -1, None,
+                        self.on_method_return, invocation)
+
+    def on_method_call_from_proxy(self, sender, path, iface, method,
+                                  params, invocation):
+        logging.debug("Call from proxy: %s %s.%s()", path, iface, method)
+        self.source.call(self.service, path, iface, method,
+                         params, None, Gio.DBusCallFlags.NONE, -1, None,
+                         self.on_method_return, invocation)
+
+    def on_method_return(self, conn, result, invocation):
+        try:
+            logging.debug("Finishing call: %s %s.%s()",
+                          invocation.get_object_path(),
+                          invocation.get_interface_name(),
+                          invocation.get_method_name())
+            reply = conn.call_with_unix_fd_list_finish(result)
+            invocation.return_value_with_unix_fd_list(reply[0],
+                                                      reply.out_fd_list)
+        except GLib.Error as e:
+            _, name, message = e.message.split(":", 2)
+            invocation.return_dbus_error(name, message.strip())
+
+    def on_signal_from_source(self, sender, path, iface, signal, params):
+        logging.debug("Signal from source: %s %s.%s", path, iface, signal)
+        if iface == "org.freedesktop.DBus.ObjectManager":
+            if signal == "InterfacesAdded":
+                dest_path = params.get_child_value(0).get_string()
+                self.mirror_source_on_proxy(self.service, dest_path)
+            if signal == "InterfacesRemoved":
+                dest_path = params.get_child_value(0).get_string()
+                for dest_iface in params.get_child_value(1).get_strv():
+                    self.unregister_object(self.proxy, dest_path, dest_iface)
+        self.proxy.emit_signal(None, path, iface, signal, params)
+
+    def on_signal_from_proxy(self, sender, path, iface, signal, params):
+        logging.debug("Signal from proxy: %s %s.%s", path, iface, signal)
+        self.source.emit_signal(None, path, iface, signal, params)
+
+
+class BluezProxy(DBusServiceProxy):
+
+    def on_method_call_from_proxy(self, sender, path, iface, method,
+                                  params, invocation):
+
+        if (iface == "org.bluez.GattManager1" and
+                method == "RegisterApplication"):
+            app_path = params.get_child_value(0).get_string()
+            logging.info("Mirroring GATT application: %s %s", sender, app_path)
+            self.mirror_proxy_on_source(sender, app_path)
+
+        if iface == "org.bluez.LEAdvertisingManager1":
+            if method == "RegisterAdvertisement":
+                app_path = params.get_child_value(0).get_string()
+                logging.info("Mirroring advertiser: %s %s", sender, app_path)
+                self.mirror_proxy_on_source(sender, app_path)
+
+        super().on_method_call_from_proxy(sender, path, iface, method,
+                                          params, invocation)
+
+
+parser = ArgumentParser(description="BlueZ D-Bus proxy")
+parser.add_argument(
+    "-v", "--verbose", action="store_true",
+    help="enable debug output")
+parser.add_argument(
+    "--bus-source", metavar="ADDRESS", default="system",
+    help="""address of the source D-Bus bus; it can be a bus address string or
+    'session' or 'system' keywords; default is '%(default)s'""")
+parser.add_argument(
+    "--bus-proxy", metavar="ADDRESS", required=True,
+    help="""address of the proxy D-Bus bus""")
+
+args = parser.parse_args()
+logging.basicConfig(level=logging.DEBUG if args.verbose else logging.INFO)
+
+BluezProxy(args.bus_source, args.bus_proxy, "org.bluez")
+GLib.MainLoop().run()
diff --git a/src/platform/Linux/ConnectivityUtils.cpp b/src/platform/Linux/ConnectivityUtils.cpp
index 8084ceca43b55a..72148d75fa64fc 100644
--- a/src/platform/Linux/ConnectivityUtils.cpp
+++ b/src/platform/Linux/ConnectivityUtils.cpp
@@ -261,16 +261,32 @@ InterfaceTypeEnum ConnectivityUtils::GetInterfaceConnectionType(const char * ifn
     {
         ret = InterfaceTypeEnum::kWiFi;
     }
-    else if ((strncmp(ifname, "en", 2) == 0) || (strncmp(ifname, "eth", 3) == 0))
+    else
     {
-        struct ethtool_cmd ecmd = {};
-        ecmd.cmd                = ETHTOOL_GSET;
-        struct ifreq ifr        = {};
-        ifr.ifr_data            = reinterpret_cast<char *>(&ecmd);
-        Platform::CopyString(ifr.ifr_name, ifname);
+        // During tests in CI WiFi interfaces are created by mac80211_hwsim driver
+        // Unfortunately, this driver does not support SIOCGIWNAME so we need to check it in a different way.
+        struct ethtool_drvinfo drvinfo = {};
+        struct ifreq ifr_driver        = {};
+        drvinfo.cmd                    = ETHTOOL_GDRVINFO;
+        ifr_driver.ifr_data            = reinterpret_cast<char *>(&drvinfo);
+        Platform::CopyString(ifr_driver.ifr_name, ifname);
+        if (ioctl(sock, SIOCETHTOOL, &ifr_driver) == 0 && strcmp(drvinfo.driver, "mac80211_hwsim") == 0)
+        {
+            ret = InterfaceTypeEnum::kWiFi;
+        }
+        else if ((strncmp(ifname, "en", 2) == 0) || (strncmp(ifname, "eth", 3) == 0))
+        {
+            struct ethtool_cmd ecmd = {};
+            ecmd.cmd                = ETHTOOL_GSET;
+            struct ifreq ifr        = {};
+            ifr.ifr_data            = reinterpret_cast<char *>(&ecmd);
+            Platform::CopyString(ifr.ifr_name, ifname);
 
-        if (ioctl(sock, SIOCETHTOOL, &ifr) != -1)
-            ret = InterfaceTypeEnum::kEthernet;
+            if (ioctl(sock, SIOCETHTOOL, &ifr) != -1)
+            {
+                ret = InterfaceTypeEnum::kEthernet;
+            }
+        }
     }
 
     close(sock);
diff --git a/zzz_generated/chip-tool/zap-generated/cluster/logging/EntryToText.cpp b/zzz_generated/chip-tool/zap-generated/cluster/logging/EntryToText.cpp
index c97ff2590fa60b..c75f9a7a096612 100644
--- a/zzz_generated/chip-tool/zap-generated/cluster/logging/EntryToText.cpp
+++ b/zzz_generated/chip-tool/zap-generated/cluster/logging/EntryToText.cpp
@@ -6697,4 +6697,4 @@ char const * GeneratedCommandIdToText(chip::ClusterId cluster, chip::CommandId i
     default:
         return "Unknown";
     }
-}
+}
\ No newline at end of file
diff --git a/zzz_generated/chip-tool/zap-generated/cluster/logging/EntryToText.h b/zzz_generated/chip-tool/zap-generated/cluster/logging/EntryToText.h
index 27fa6512fbdd01..f310593ba9e3e2 100644
--- a/zzz_generated/chip-tool/zap-generated/cluster/logging/EntryToText.h
+++ b/zzz_generated/chip-tool/zap-generated/cluster/logging/EntryToText.h
@@ -27,4 +27,4 @@ char const * AttributeIdToText(chip::ClusterId cluster, chip::AttributeId id);
 
 char const * AcceptedCommandIdToText(chip::ClusterId cluster, chip::CommandId id);
 
-char const * GeneratedCommandIdToText(chip::ClusterId cluster, chip::CommandId id);
+char const * GeneratedCommandIdToText(chip::ClusterId cluster, chip::CommandId id);
\ No newline at end of file