diff --git a/.github/workflows/darwin.yaml b/.github/workflows/darwin.yaml
index e2ee439bbdb8ea..229c6cca656d26 100644
--- a/.github/workflows/darwin.yaml
+++ b/.github/workflows/darwin.yaml
@@ -116,11 +116,10 @@ jobs:
 
                   export TEST_RUNNER_ASAN_OPTIONS=__CURRENT_VALUE__:detect_stack_use_after_return=1
 
-                  # Disable BLE (CHIP_IS_BLE=NO) because the app does not have the permission to use it and that may crash the CI.
                   xcodebuild test -target "Matter" -scheme "Matter Framework Tests" \
                     -resultBundlePath /tmp/darwin/framework-tests/TestResults.xcresult \
                     -sdk macosx ${{ matrix.options.arguments }} \
-                    CHIP_IS_BLE=NO GCC_PREPROCESSOR_DEFINITIONS='${inherited} ${{ matrix.options.defines }}' \
+                    GCC_PREPROCESSOR_DEFINITIONS='${inherited} ${{ matrix.options.defines }}' \
                     > >(tee /tmp/darwin/framework-tests/darwin-tests.log) 2> >(tee /tmp/darwin/framework-tests/darwin-tests-err.log >&2)
             - name: Generate Summary
               if: always()
diff --git a/src/controller/python/chip/ble/darwin/Scanning.mm b/src/controller/python/chip/ble/darwin/Scanning.mm
index 564a984e094369..404008fbddd0cf 100644
--- a/src/controller/python/chip/ble/darwin/Scanning.mm
+++ b/src/controller/python/chip/ble/darwin/Scanning.mm
@@ -1,7 +1,7 @@
 #include <ble/Ble.h>
 #include <lib/support/CHIPMem.h>
 #include <lib/support/logging/CHIPLogging.h>
-#include <platform/Darwin/MTRUUIDHelper.h>
+#include <platform/Darwin/BleUtils.h>
 
 #import <CoreBluetooth/CoreBluetooth.h>
 
@@ -45,7 +45,7 @@ - (id)initWithContext:(PyObject *)context
 {
     self = [super init];
     if (self) {
-        self.shortServiceUUID = [MTRUUIDHelper GetShortestServiceUUID:&chip::Ble::CHIP_BLE_SVC_ID];
+        self.shortServiceUUID = chip::DeviceLayer::Internal::CBUUIDFromBleUUID(chip::Ble::CHIP_BLE_SVC_ID);
 
         _workQueue = dispatch_queue_create("com.chip.python.ble.work_queue", DISPATCH_QUEUE_SERIAL);
         _timer = dispatch_source_create(DISPATCH_SOURCE_TYPE_TIMER, 0, 0, _workQueue);
@@ -78,7 +78,7 @@ - (void)centralManager:(CBCentralManager *)central
 
     NSDictionary * servicesData = [advertisementData objectForKey:CBAdvertisementDataServiceDataKey];
     for (CBUUID * serviceUUID in servicesData) {
-        if (![serviceUUID.data isEqualToData:_shortServiceUUID.data]) {
+        if (![serviceUUID isEqualTo:_shortServiceUUID]) {
             continue;
         }
         NSData * serviceData = [servicesData objectForKey:serviceUUID];
diff --git a/src/darwin/Framework/CHIP/MTRCommissionableBrowser.mm b/src/darwin/Framework/CHIP/MTRCommissionableBrowser.mm
index 8a111a6a019bdb..0bbdc12c188059 100644
--- a/src/darwin/Framework/CHIP/MTRCommissionableBrowser.mm
+++ b/src/darwin/Framework/CHIP/MTRCommissionableBrowser.mm
@@ -26,9 +26,11 @@
 #include <controller/CHIPDeviceController.h>
 #include <lib/dnssd/platform/Dnssd.h>
 #include <platform/CHIPDeviceLayer.h>
+#include <platform/Darwin/BleUtils.h>
 
 using namespace chip::Dnssd;
 using namespace chip::DeviceLayer;
+using namespace chip::DeviceLayer::Internal;
 
 #if CONFIG_NETWORK_LAYER_BLE
 #include <platform/Darwin/BleScannerDelegate.h>
@@ -39,6 +41,8 @@
 
 using namespace chip::Tracing::DarwinFramework;
 
+@class CBPeripheral;
+
 @implementation MTRCommissionableBrowserResultInterfaces
 @end
 
@@ -48,6 +52,7 @@ @interface MTRCommissionableBrowserResult ()
 @property (nonatomic) NSNumber * productID;
 @property (nonatomic) NSNumber * discriminator;
 @property (nonatomic) BOOL commissioningMode;
+@property (nonatomic, strong, nullable) CBPeripheral * peripheral;
 @end
 
 @implementation MTRCommissionableBrowserResult
@@ -302,6 +307,7 @@ void OnBleScanAdd(BLE_CONNECTION_OBJECT connObj, const ChipBLEDeviceIdentificati
         result.discriminator = @(info.GetDeviceDiscriminator());
         result.commissioningMode = YES;
         result.params = chip::MakeOptional(chip::Controller::SetUpCodePairerParameters(connObj, false /* connected */));
+        result.peripheral = CBPeripheralFromBleConnObject(connObj); // avoid params holding a dangling pointer
 
         MATTER_LOG_METRIC(kMetricBLEDevicesAdded, ++mBLEDevicesAdded);
 
diff --git a/src/darwin/Framework/CHIPTests/MTRBleTests.m b/src/darwin/Framework/CHIPTests/MTRBleTests.m
new file mode 100644
index 00000000000000..8ec6698ced0bf5
--- /dev/null
+++ b/src/darwin/Framework/CHIPTests/MTRBleTests.m
@@ -0,0 +1,192 @@
+/**
+ *    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 "MTRMockCB.h"
+#import "MTRTestCase.h"
+#import "MTRTestKeys.h"
+#import "MTRTestStorage.h"
+
+#import <Matter/Matter.h>
+#import <XCTest/XCTest.h>
+#import <stdatomic.h>
+
+NS_ASSUME_NONNULL_BEGIN
+
+@interface MTRBleTests : MTRTestCase
+@end
+
+@interface TestBrowserDelegate : NSObject <MTRCommissionableBrowserDelegate>
+@property (strong) void (^onDidFindCommissionableDevice)(MTRDeviceController *, MTRCommissionableBrowserResult *);
+@property (strong) void (^onDidRemoveCommissionableDevice)(MTRDeviceController *, MTRCommissionableBrowserResult *);
+@end
+
+@implementation TestBrowserDelegate
+
+- (void)controller:(nonnull MTRDeviceController *)controller didFindCommissionableDevice:(MTRCommissionableBrowserResult *)device
+{
+    __auto_type block = self.onDidFindCommissionableDevice;
+    if (block) {
+        block(controller, device);
+    }
+}
+
+- (void)controller:(nonnull MTRDeviceController *)controller didRemoveCommissionableDevice:(MTRCommissionableBrowserResult *)device
+{
+    __auto_type block = self.onDidRemoveCommissionableDevice;
+    if (block) {
+        block(controller, device);
+    }
+}
+
+@end
+
+MTRDeviceController * sController;
+
+@implementation MTRBleTests
+
+- (void)setUp
+{
+    [super setUp];
+
+    [self.class.mockCoreBluetooth reset];
+
+    sController = [MTRTestCase createControllerOnTestFabric];
+    XCTAssertNotNil(sController);
+}
+
+- (void)tearDown
+{
+    [sController shutdown];
+    sController = nil;
+    [[MTRDeviceControllerFactory sharedInstance] stopControllerFactory];
+
+    [super tearDown];
+}
+
+- (void)testBleCommissionableBrowserResultAdditionAndRemoval
+{
+    __block MTRCommissionableBrowserResult * device;
+    XCTestExpectation * didFindDevice = [self expectationWithDescription:@"did find device"];
+    TestBrowserDelegate * delegate = [[TestBrowserDelegate alloc] init];
+    delegate.onDidFindCommissionableDevice = ^(MTRDeviceController * controller, MTRCommissionableBrowserResult * result) {
+        if ([result.instanceName isEqualToString:@"BLE"]) { // TODO: This is a lame API
+            XCTAssertNil(device);
+            XCTAssertEqualObjects(result.vendorID, @0xfff1);
+            XCTAssertEqualObjects(result.productID, @0x1234);
+            XCTAssertEqualObjects(result.discriminator, @0x444);
+            device = result;
+            [didFindDevice fulfill];
+        }
+    };
+
+    XCTestExpectation * didRemoveDevice = [self expectationWithDescription:@"did remove device"];
+    delegate.onDidRemoveCommissionableDevice = ^(MTRDeviceController * controller, MTRCommissionableBrowserResult * result) {
+        if ([result.instanceName isEqualToString:@"BLE"]) {
+            XCTAssertNotNil(device);
+            XCTAssertEqual(result, device);
+            [didRemoveDevice fulfill];
+        }
+    };
+
+    XCTAssertTrue([sController startBrowseForCommissionables:delegate queue:dispatch_get_main_queue()]);
+
+    NSUUID * peripheralID = [NSUUID UUID];
+    [self.class.mockCoreBluetooth addMockCommissionableMatterDeviceWithIdentifier:peripheralID vendorID:@0xfff1 productID:@0x1234 discriminator:@0x444];
+    [self.class.mockCoreBluetooth removeMockPeripheralWithIdentifier:peripheralID];
+
+    // BleConnectionDelegateImpl kCachePeripheralTimeoutInSeconds is approximately 10 seconds
+    [self waitForExpectations:@[ didFindDevice, didRemoveDevice ] timeout:14 enforceOrder:YES];
+    XCTAssertTrue([sController stopBrowseForCommissionables]);
+}
+
+- (void)testBleCommissionAfterStopBrowseUAF
+{
+    __block MTRCommissionableBrowserResult * device;
+    XCTestExpectation * didFindDevice = [self expectationWithDescription:@"did find device"];
+    TestBrowserDelegate * delegate = [[TestBrowserDelegate alloc] init];
+    delegate.onDidFindCommissionableDevice = ^(MTRDeviceController * controller, MTRCommissionableBrowserResult * result) {
+        if ([result.instanceName isEqualToString:@"BLE"]) {
+            XCTAssertNil(device);
+            XCTAssertEqualObjects(result.vendorID, @0xfff1);
+            XCTAssertEqualObjects(result.productID, @0x1234);
+            XCTAssertEqualObjects(result.discriminator, @0x444);
+            device = result;
+            [didFindDevice fulfill];
+        }
+    };
+
+    XCTAssertTrue([sController startBrowseForCommissionables:delegate queue:dispatch_get_main_queue()]);
+
+    NSUUID * peripheralID = [NSUUID UUID];
+    [self.class.mockCoreBluetooth addMockCommissionableMatterDeviceWithIdentifier:peripheralID vendorID:@0xfff1 productID:@0x1234 discriminator:@0x444];
+    [self waitForExpectations:@[ didFindDevice ] timeout:2 enforceOrder:YES];
+
+    XCTAssertTrue([sController stopBrowseForCommissionables]);
+
+    // Attempt to use the MTRCommissionableBrowserResult after we stopped browsing
+    // This used to result in a UAF because BLE_CONNECTION_OBJECT is a void*
+    // carrying a CBPeripheral without retaining it. When browsing is stopped,
+    // BleConnectionDelegateImpl releases all cached CBPeripherals.
+    MTRSetupPayload * payload = [[MTRSetupPayload alloc] initWithSetupPasscode:@54321 discriminator:@0x444];
+    [sController setupCommissioningSessionWithDiscoveredDevice:device
+                                                       payload:payload
+                                                     newNodeID:@999
+                                                         error:NULL];
+    [sController cancelCommissioningForNodeID:@999 error:NULL];
+}
+
+- (void)testShutdownBlePowerOffRaceUAF
+{
+    // Attempt a PASE connection over BLE, this will call BleConnectionDelegateImpl::NewConnection()
+    MTRSetupPayload * payload = [[MTRSetupPayload alloc] initWithSetupPasscode:@54321 discriminator:@0xb1e];
+    payload.discoveryCapabilities = MTRDiscoveryCapabilitiesBLE;
+    NSError * error;
+    XCTAssertTrue([sController setupCommissioningSessionWithPayload:payload newNodeID:@999 error:&error],
+        "setupCommissioningSessionWithPayload failed: %@", error);
+
+    // Create a race between shutdown and a CBManager callback that used to provoke a UAF.
+    // Note that on the order of 100 iterations can be necessary to reproduce the crash.
+    __block atomic_int tasks = 2;
+    dispatch_semaphore_t done = dispatch_semaphore_create(0);
+
+    dispatch_block_t shutdown = ^{
+        // Shut down the controller. This causes the SetupCodePairer to call
+        // BleConnectionDelegateImpl::CancelConnection(), then the SetupCodePairer
+        // is deallocated along with the DeviceCommissioner
+        [sController shutdown];
+        sController = nil;
+        if (atomic_fetch_sub(&tasks, 1) == 1) {
+            dispatch_semaphore_signal(done);
+        }
+    };
+    dispatch_block_t powerOff = ^{
+        // Cause CBPeripheralManager to signal a state change that
+        // triggers a callback to the SetupCodePairer
+        self.class.mockCoreBluetooth.state = CBManagerStatePoweredOff;
+        if (atomic_fetch_sub(&tasks, 1) == 1) {
+            dispatch_semaphore_signal(done);
+        }
+    };
+
+    dispatch_queue_t pool = dispatch_get_global_queue(QOS_CLASS_DEFAULT, 0);
+    dispatch_async(pool, shutdown);
+    dispatch_async(pool, powerOff);
+    dispatch_wait(done, DISPATCH_TIME_FOREVER);
+}
+
+@end
+
+NS_ASSUME_NONNULL_END
diff --git a/src/darwin/Framework/CHIPTests/MTRCommissionableBrowserTests.m b/src/darwin/Framework/CHIPTests/MTRCommissionableBrowserTests.m
index d2b5f797e4514b..e5e8354f8bf794 100644
--- a/src/darwin/Framework/CHIPTests/MTRCommissionableBrowserTests.m
+++ b/src/darwin/Framework/CHIPTests/MTRCommissionableBrowserTests.m
@@ -17,6 +17,7 @@
 
 #import <Matter/Matter.h>
 
+#import "MTRMockCB.h"
 #import "MTRTestCase+ServerAppRunner.h"
 #import "MTRTestCase.h"
 #import "MTRTestKeys.h"
@@ -24,21 +25,20 @@
 
 // Fixture 1: chip-all-clusters-app --KVS "$(mktemp -t chip-test-kvs)" --interface-id -1
 
-static const uint16_t kLocalPort = 5541;
 static const uint16_t kTestVendorId = 0xFFF1u;
-static const __auto_type kTestProductIds = @[ @(0x8000u), @(0x8001u) ];
-static const __auto_type kTestDiscriminators = @[ @(2000), @(3839u), @(3840u) ];
+static const __auto_type kTestProductIds = @[ @(0x8000u), @(0x8001u), @(0x8002u) ];
+static const __auto_type kTestDiscriminators = @[ @(2000), @(3839u), @(3840u), @(0xb1e) ];
 static const uint16_t kDiscoverDeviceTimeoutInSeconds = 10;
-static const uint16_t kExpectedDiscoveredDevicesCount = 3;
+static const uint16_t kExpectedDiscoveredDevicesCount = 4;
 
 // Singleton controller we use.
 static MTRDeviceController * sController = nil;
 
-static NSString * kInstanceNameKey = @"instanceName";
-static NSString * kVendorIDKey = @"vendorID";
-static NSString * kProductIDKey = @"productID";
-static NSString * kDiscriminatorKey = @"discriminator";
-static NSString * kCommissioningModeKey = @"commissioningMode";
+static NSString * const kInstanceNameKey = @"instanceName";
+static NSString * const kVendorIDKey = @"vendorID";
+static NSString * const kProductIDKey = @"productID";
+static NSString * const kDiscriminatorKey = @"discriminator";
+static NSString * const kCommissioningModeKey = @"commissioningMode";
 
 static NSDictionary<NSString *, id> * ResultSnapshot(MTRCommissionableBrowserResult * result)
 {
@@ -112,7 +112,9 @@ - (void)controller:(MTRDeviceController *)controller didFindCommissionableDevice
     __auto_type discriminator = device.discriminator;
     __auto_type commissioningMode = device.commissioningMode;
 
-    XCTAssertEqual(instanceName.length, 16); // The  instance name is random, so just ensure the len is right.
+    if (![instanceName isEqual:@"BLE"]) {
+        XCTAssertEqual(instanceName.length, 16); // The  instance name is random, so just ensure the len is right.
+    }
     XCTAssertEqualObjects(vendorId, @(kTestVendorId));
     XCTAssertTrue([kTestProductIds containsObject:productId]);
     XCTAssertTrue([kTestDiscriminators containsObject:discriminator]);
@@ -162,28 +164,9 @@ + (void)setUp
 {
     [super setUp];
 
-    __auto_type * factory = [MTRDeviceControllerFactory sharedInstance];
-    XCTAssertNotNil(factory);
-
-    __auto_type * storage = [[MTRTestStorage alloc] init];
-    __auto_type * factoryParams = [[MTRDeviceControllerFactoryParams alloc] initWithStorage:storage];
-    factoryParams.port = @(kLocalPort);
-
-    BOOL ok = [factory startControllerFactory:factoryParams error:nil];
-    XCTAssertTrue(ok);
-
-    __auto_type * testKeys = [[MTRTestKeys alloc] init];
-    XCTAssertNotNil(testKeys);
-
-    __auto_type * params = [[MTRDeviceControllerStartupParams alloc] initWithIPK:testKeys.ipk fabricID:@(1) nocSigner:testKeys];
-    params.vendorID = @(kTestVendorId);
-
-    MTRDeviceController * controller = [factory createControllerOnNewFabric:params error:nil];
-    XCTAssertNotNil(controller);
-
-    sController = controller;
+    sController = [MTRTestCase createControllerOnTestFabric];
 
-    // Start the helper apps our tests use.
+    // Start the helper apps our tests use. Note these payloads match kTestDiscriminators etc.
     for (NSString * payload in @[
              @"MT:Y.K90SO527JA0648G00",
              @"MT:-24J0AFN00I40648G00",
@@ -197,10 +180,10 @@ + (void)setUp
 
 + (void)tearDown
 {
-    MTRDeviceController * controller = sController;
-    XCTAssertNotNil(controller);
-    [controller shutdown];
-    XCTAssertFalse([controller isRunning]);
+    XCTAssertNotNil(sController);
+    [sController shutdown];
+    XCTAssertFalse([sController isRunning]);
+    sController = nil;
 
     [[MTRDeviceControllerFactory sharedInstance] stopControllerFactory];
 
@@ -211,6 +194,7 @@ - (void)setUp
 {
     [super setUp];
     [self setContinueAfterFailure:NO];
+    [self.class.mockCoreBluetooth reset];
 }
 
 - (void)test001_StartBrowseAndStopBrowse
@@ -218,11 +202,26 @@ - (void)test001_StartBrowseAndStopBrowse
     __auto_type delegate = [[DeviceScannerDelegate alloc] init];
     dispatch_queue_t dispatchQueue = dispatch_queue_create("com.chip.discover", DISPATCH_QUEUE_SERIAL);
 
+    XCTestExpectation * bleScanExpectation = [self expectationWithDescription:@"did start BLE scan"];
+    self.class.mockCoreBluetooth.onScanForPeripheralsWithServicesOptions = ^(NSArray<CBUUID *> * _Nullable serviceUUIDs, NSDictionary<NSString *, id> * _Nullable options) {
+        XCTAssertEqual(serviceUUIDs.count, 1);
+        [bleScanExpectation fulfill];
+    };
+
     // Start browsing
     XCTAssertTrue([sController startBrowseForCommissionables:delegate queue:dispatchQueue]);
 
+    [self waitForExpectations:@[ bleScanExpectation ] timeout:1];
+
+    XCTestExpectation * bleStopExpectation = [self expectationWithDescription:@"did stop BLE scan"];
+    self.class.mockCoreBluetooth.onStopScan = ^{
+        [bleStopExpectation fulfill];
+    };
+
     // Stop browsing
     XCTAssertTrue([sController stopBrowseForCommissionables]);
+
+    [self waitForExpectations:@[ bleStopExpectation ] timeout:1];
 }
 
 - (void)test002_StartBrowseAndStopBrowseMultipleTimes
@@ -264,12 +263,18 @@ - (void)test004_StartBrowseWhileBrowsing
     XCTAssertTrue([sController stopBrowseForCommissionables]);
 }
 
-- (void)test005_StartBrowseGetCommissionableOverMdns
+- (void)test005_StartBrowseGetCommissionableOverMdnsAndBle
 {
     __auto_type expectation = [self expectationWithDescription:@"Commissionable devices Found"];
     __auto_type delegate = [[DeviceScannerDelegate alloc] initWithExpectation:expectation];
     dispatch_queue_t dispatchQueue = dispatch_queue_create("com.chip.discover", DISPATCH_QUEUE_SERIAL);
 
+    // Mock a commissionable device advertising over BLE
+    [self.class.mockCoreBluetooth addMockCommissionableMatterDeviceWithIdentifier:[NSUUID UUID]
+                                                                         vendorID:@(kTestVendorId)
+                                                                        productID:@0x8002
+                                                                    discriminator:@0xb1e];
+
     // Start browsing
     XCTAssertTrue([sController startBrowseForCommissionables:delegate queue:dispatchQueue]);
 
diff --git a/src/darwin/Framework/CHIPTests/TestHelpers/MTRMockCB.h b/src/darwin/Framework/CHIPTests/TestHelpers/MTRMockCB.h
new file mode 100644
index 00000000000000..022b7b876b156e
--- /dev/null
+++ b/src/darwin/Framework/CHIPTests/TestHelpers/MTRMockCB.h
@@ -0,0 +1,61 @@
+/**
+ *    Copyright (c) 2025 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 <CoreBluetooth/CoreBluetooth.h>
+#import <Foundation/Foundation.h>
+
+NS_ASSUME_NONNULL_BEGIN
+
+/// Instantiating this class will intercept any future calls that
+/// allocate `CBCentralManager` objects to return mock objects instead.
+/// Only one instance of this class should exist at any one time.
+@interface MTRMockCB : NSObject
+
+/// Stops mock `CBCentralManager` allocations and disables existing mocks.
+/// Failure to call this method may result in leaks or other issues.
+- (void)stopMocking;
+
+/// Resets `on*` hooks and mocked peripherals but does not stop mocking.
+- (void)reset;
+
+// Adds a mocked peripheral and causes any mocked `CBCentralManager`
+// instances to discover this as a `CBPeripheral`, if they are scanning.
+// The provided identifier becomes the `identifier` of the `CBPeripheral`.
+- (void)addMockPeripheralWithIdentifier:(NSUUID *)identifier
+                               services:(NSArray<CBUUID *> *)services
+                      advertisementData:(nullable NSDictionary<NSString *, id> *)advertisementData;
+
+// Convenience version of `addMockPeripheralWithIdentifier:...` that
+// advertises the relevant service with advertisement data as defined
+// in the "Matter BLE Service Data payload format" section of the spec.
+- (void)addMockCommissionableMatterDeviceWithIdentifier:(NSUUID *)identifier
+                                               vendorID:(NSNumber *)vendorID
+                                              productID:(NSNumber *)productID
+                                          discriminator:(NSNumber *)discriminator;
+
+- (void)removeMockPeripheralWithIdentifier:(NSUUID *)identifier;
+
+/// Mocked state. Defaults to CBManagerStatePoweredOn.
+@property (readwrite, assign) CBManagerState state;
+
+@property (readwrite, strong, nullable) void (^onScanForPeripheralsWithServicesOptions)
+    (NSArray<CBUUID *> * _Nullable serviceUUIDs, NSDictionary<NSString *, id> * _Nullable options);
+
+@property (readwrite, strong, nullable) void (^onStopScan)(void);
+
+@end
+
+NS_ASSUME_NONNULL_END
diff --git a/src/darwin/Framework/CHIPTests/TestHelpers/MTRMockCB.m b/src/darwin/Framework/CHIPTests/TestHelpers/MTRMockCB.m
new file mode 100644
index 00000000000000..0098e27bda53cf
--- /dev/null
+++ b/src/darwin/Framework/CHIPTests/TestHelpers/MTRMockCB.m
@@ -0,0 +1,690 @@
+/**
+ *    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 "MTRMockCB.h"
+
+#import "MTRDefines_Internal.h"
+
+#import <XCTest/XCTest.h>
+#import <objc/runtime.h>
+#import <os/log.h>
+#import <stdatomic.h>
+
+NS_ASSUME_NONNULL_BEGIN
+
+@interface MTRMockCBPeripheralDetails : NSObject
+
+- (instancetype)initWithIdentifier:(NSUUID *)identifier;
+
+@property (readonly, nonatomic, weak) MTRMockCB * mock;
+@property (readonly, nonatomic, strong) NSUUID * identifier;
+@property (readonly, nonatomic, copy) NSString * name;
+@property (readonly, nonatomic, copy) NSDictionary<NSString *, id> * advertisementData;
+
+@property (nonatomic, assign) CBPeripheralState state;
+@property (nonatomic, nullable, copy) NSDictionary<NSString *, id> * extraAdvertisementData;
+@property (nonatomic, copy) NSArray<CBUUID *> * services;
+
+@end
+
+@interface MTRMockCBCentralManager : NSObject
+
+- (instancetype)_initWithMock:(MTRMockCB *)mock;
+- (void)_didUpdateState;
+- (void)_maybeDiscoverPeripheral:(MTRMockCBPeripheralDetails *)details;
+
+// MARK: CBManager
+
+@property (nonatomic, assign, readonly) CBManagerState state;
+@property (nonatomic, assign, readonly) CBManagerAuthorization authorization;
+@property (class, nonatomic, assign, readonly) CBManagerAuthorization authorization;
+
+// MARK: CBCentralManager
+
+@property (nonatomic, weak, nullable) id<CBCentralManagerDelegate> delegate;
+@property (nonatomic, assign, readonly) BOOL isScanning;
+
++ (BOOL)supportsFeatures:(CBCentralManagerFeature)features;
+
+- (instancetype)init;
+- (instancetype)initWithDelegate:(nullable id<CBCentralManagerDelegate>)delegate
+                           queue:(nullable dispatch_queue_t)queue;
+- (instancetype)initWithDelegate:(nullable id<CBCentralManagerDelegate>)delegate
+                           queue:(nullable dispatch_queue_t)queue
+                         options:(nullable NSDictionary<NSString *, id> *)options;
+
+- (NSArray<CBPeripheral *> *)retrievePeripheralsWithIdentifiers:(NSArray<NSUUID *> *)identifiers;
+- (NSArray<CBPeripheral *> *)retrieveConnectedPeripheralsWithServices:(NSArray<CBUUID *> *)serviceUUIDs;
+- (void)scanForPeripheralsWithServices:(nullable NSArray<CBUUID *> *)serviceUUIDs options:(nullable NSDictionary<NSString *, id> *)options;
+- (void)stopScan;
+- (void)connectPeripheral:(CBPeripheral *)peripheral options:(nullable NSDictionary<NSString *, id> *)options;
+- (void)cancelPeripheralConnection:(CBPeripheral *)peripheral;
+- (void)registerForConnectionEventsWithOptions:(nullable NSDictionary<CBConnectionEventMatchingOption, id> *)options;
+
+@end
+
+@interface MTRMockCBPeripheral : NSObject <NSCopying>
+
+- (instancetype)_initWithDetails:(MTRMockCBPeripheralDetails *)details manager:(MTRMockCBCentralManager *)manager;
+
+@property (readonly, strong, nonatomic) MTRMockCBCentralManager * manager; // not API, but used by BlePlatformDelegateImpl via KVC
+
+// MARK: CBPeer
+
+@property (readonly, nonatomic) NSUUID * identifier;
+
+// MARK: CBPeripheral
+
+@property (weak, nonatomic, nullable) id<CBPeripheralDelegate> delegate;
+@property (retain, readonly, nullable) NSString * name;
+@property (retain, readonly, nullable) NSNumber * RSSI;
+@property (readonly) CBPeripheralState state;
+@property (retain, readonly, nullable) NSArray<CBService *> * services;
+@property (readonly) BOOL canSendWriteWithoutResponse;
+@property (readonly) BOOL ancsAuthorized;
+
+- (void)readRSSI;
+- (void)discoverServices:(nullable NSArray<CBUUID *> *)serviceUUIDs;
+- (void)discoverIncludedServices:(nullable NSArray<CBUUID *> *)includedServiceUUIDs forService:(CBService *)service;
+- (void)discoverCharacteristics:(nullable NSArray<CBUUID *> *)characteristicUUIDs forService:(CBService *)service;
+- (void)readValueForCharacteristic:(CBCharacteristic *)characteristic;
+- (NSUInteger)maximumWriteValueLengthForType:(CBCharacteristicWriteType)type;
+- (void)writeValue:(NSData *)data forCharacteristic:(CBCharacteristic *)characteristic type:(CBCharacteristicWriteType)type;
+- (void)setNotifyValue:(BOOL)enabled forCharacteristic:(CBCharacteristic *)characteristic;
+- (void)discoverDescriptorsForCharacteristic:(CBCharacteristic *)characteristic;
+- (void)readValueForDescriptor:(CBDescriptor *)descriptor;
+- (void)writeValue:(NSData *)data forDescriptor:(CBDescriptor *)descriptor;
+- (void)openL2CAPChannel:(CBL2CAPPSM)PSM;
+
+@end
+
+static NSString * CBManagerStateAsString(CBManagerState state)
+{
+    switch (state) {
+    case CBManagerStateUnknown:
+        return @"CBManagerStateUnknown";
+    case CBManagerStateResetting:
+        return @"CBManagerStateResetting";
+    case CBManagerStateUnsupported:
+        return @"CBManagerStateUnsupported";
+    case CBManagerStateUnauthorized:
+        return @"CBManagerStateUnauthorized";
+    case CBManagerStatePoweredOff:
+        return @"CBManagerStatePoweredOff";
+    case CBManagerStatePoweredOn:
+        return @"CBManagerStatePoweredOn";
+    }
+    return [NSString stringWithFormat:@"CBManagerState(%ld)", (long) state];
+}
+
+@implementation MTRMockCB {
+@package
+    os_log_t _log;
+    dispatch_queue_t _queue;
+    os_block_t _invalidate;
+    NSHashTable<MTRMockCBCentralManager *> * _managers;
+    NSMutableDictionary<NSUUID *, MTRMockCBPeripheralDetails *> * _peripherals;
+    CBManagerState _state;
+}
+
+static void InterceptClassMethod(__strong os_block_t * inOutCleanup, Class cls, SEL sel, id block)
+{
+    Method method = class_getClassMethod(cls, sel); // may return an inherited method
+    if (!method) {
+        NSString * reason = [NSString stringWithFormat:@"+[%@ %@] does not exist",
+                                      NSStringFromClass(cls), NSStringFromSelector(sel)];
+        @throw [NSException exceptionWithName:NSInternalInconsistencyException reason:reason userInfo:nil];
+    }
+    IMP originalImp = method_getImplementation(method);
+
+    // Try to add the method first, in case it came from a super class.
+    // Note we need to pass the meta-class to class_addMethod().
+    IMP newImp = imp_implementationWithBlock(block);
+    if (class_addMethod(object_getClass(cls), sel, newImp, method_getTypeEncoding(method))) {
+        method = class_getClassMethod(cls, sel); // look up again so we clean up the method we added
+    } else {
+        method_setImplementation(method, newImp);
+    }
+
+    os_block_t nextCleanup = *inOutCleanup;
+    *inOutCleanup = ^{
+        // This isn't 100% correct if we added an override of a super-class method, because
+        // there is no API for removing a method. Instead we directly point it at the
+        // inherited implementation; this is good enough for our purposes here.
+        method_setImplementation(method, originalImp);
+        imp_removeBlock(newImp); // otherwise the block might leak
+        (void) block; // keep an obvious reference to avoid `leaks` false positives before cleanup
+        nextCleanup();
+    };
+}
+
+- (instancetype)init
+{
+    self = [super init];
+    _log = os_log_create("com.csa.matter", "mock");
+
+    static atomic_flag sInitialized = ATOMIC_FLAG_INIT;
+    if (atomic_flag_test_and_set(&sInitialized)) {
+        os_log_error(_log, "CoreBluetooth mocking is already enabled");
+        return nil;
+    }
+
+    mtr_weakify(self);
+    _invalidate = ^{
+        mtr_strongify(self);
+        self->_invalidate = nil;
+        atomic_flag_clear(&sInitialized);
+    };
+
+    _queue = dispatch_queue_create("mock.cb", DISPATCH_QUEUE_SERIAL_WITH_AUTORELEASE_POOL);
+    dispatch_queue_set_specific(_queue, (__bridge void *) self, @YES, nil); // mark our queue
+
+    _managers = [NSHashTable weakObjectsHashTable];
+    _peripherals = [[NSMutableDictionary alloc] init];
+    _state = CBManagerStatePoweredOn;
+
+    os_log(_log, "Enabling CoreBluetooth mocking");
+
+    // Replace implementations of class methods we need to mock. We don't need to intercept
+    // any instance methods directly, because we're returning a mock object from `alloc`.
+    InterceptClassMethod(&_invalidate, CBCentralManager.class, @selector(alloc), ^id NS_RETURNS_RETAINED(void) {
+        mtr_strongify(self);
+        return self ? [[MTRMockCBCentralManager alloc] _initWithMock:self] : nil;
+    });
+    InterceptClassMethod(&_invalidate, CBCentralManager.class, @selector(supportsFeatures:), ^BOOL(CBCentralManagerFeature features) {
+        return [MTRMockCBCentralManager supportsFeatures:features];
+    });
+    InterceptClassMethod(&_invalidate, CBManager.class, @selector(authorization), ^CBManagerAuthorization(void) {
+        return [MTRMockCBCentralManager authorization];
+    });
+
+    return self;
+}
+
+- (void)sync:(void (^NS_NOESCAPE)(BOOL isValid))block
+{
+    // Allow `sync` to work like a recursive lock for convenience.
+    if (dispatch_get_specific((__bridge void *) self) != NULL) {
+        block(_invalidate != nil);
+    } else {
+        dispatch_sync(_queue, ^{
+            block(_invalidate != nil);
+        });
+    }
+}
+
+- (BOOL)isValid
+{
+    __block BOOL result;
+    [self sync:^(BOOL isValid) {
+        result = isValid;
+    }];
+    return result;
+}
+
+- (void)reset
+{
+    [self sync:^(BOOL isValid) {
+        [_peripherals removeAllObjects];
+        _onScanForPeripheralsWithServicesOptions = nil;
+        _onStopScan = nil;
+    }];
+}
+
+- (void)stopMocking
+{
+    [self sync:^(BOOL isValid) {
+        if (isValid) {
+            os_log(_log, "Disabling CoreBluetooth mocking");
+
+            _invalidate();
+            _invalidate = nil;
+
+            NSArray<MTRMockCBCentralManager *> * managers = [_managers allObjects];
+            _managers = nil;
+            _peripherals = nil;
+
+            if (_state != CBManagerStatePoweredOff) {
+                _state = CBManagerStatePoweredOff;
+                for (MTRMockCBCentralManager * manager in managers) {
+                    [manager _didUpdateState];
+                }
+            }
+        }
+    }];
+}
+
+- (CBManagerState)state
+{
+    __block CBManagerState result;
+    [self sync:^(BOOL isValid) {
+        result = _state;
+    }];
+    return result;
+}
+
+- (void)setState:(CBManagerState)state
+{
+    [self sync:^(BOOL isValid) {
+        if (isValid && state != _state) {
+            _state = state;
+            for (MTRMockCBCentralManager * manager in _managers) {
+                [manager _didUpdateState];
+            }
+        }
+    }];
+}
+
+- (void)addMockPeripheralWithIdentifier:(NSUUID *)identifier
+                               services:(nonnull NSArray<CBUUID *> *)services
+                      advertisementData:(nullable NSDictionary<NSString *, id> *)advertisementData
+{
+    [self sync:^(BOOL isValid) {
+        if (isValid) {
+            MTRMockCBPeripheralDetails * details = _peripherals[identifier];
+            if (!details) {
+                details = [[MTRMockCBPeripheralDetails alloc] initWithIdentifier:identifier];
+                _peripherals[identifier] = details;
+            }
+            details.services = services;
+            details.extraAdvertisementData = advertisementData;
+
+            for (MTRMockCBCentralManager * manager in _managers) {
+                [manager _maybeDiscoverPeripheral:details];
+            }
+        }
+    }];
+}
+
+- (void)addMockCommissionableMatterDeviceWithIdentifier:(NSUUID *)identifier
+                                               vendorID:(NSNumber *)vendorID
+                                              productID:(NSNumber *)productID
+                                          discriminator:(NSNumber *)discriminator
+{
+    // Note: CBUUID transparently expands 16 or 32 bit UUIDs to 128 bit as required,
+    // however the current BLEConnectionDelegateImpl has its own comparison logic
+    // that does not treat short and long UUIDs as equivalent and only works with
+    // short UUIDs. The full UUID is "0000fff6-0000-1000-8000-00805f9b34fb".
+    CBUUID * matterServiceUUID = [CBUUID UUIDWithString:@"fff6"];
+
+    // See "Matter BLE Service Data payload format" in the spec, all fields little endian.
+    const uint8_t serviceDataBytes[] = {
+        0x00, // OpCode 0x00 (Commissionable)
+        discriminator.unsignedIntValue & 0xff, (discriminator.unsignedIntValue >> 8) & 0xf,
+        vendorID.unsignedIntValue & 0xff, (vendorID.unsignedIntValue >> 8) & 0xff,
+        productID.unsignedIntValue & 0xff, (productID.unsignedIntValue >> 8) & 0xff,
+        0x00, // Flags
+    };
+    NSData * matterServiceData = [NSData dataWithBytes:serviceDataBytes length:sizeof(serviceDataBytes)];
+
+    [self addMockPeripheralWithIdentifier:[NSUUID UUID]
+                                 services:@[ matterServiceUUID ]
+                        advertisementData:@{ CBAdvertisementDataServiceDataKey : @ { matterServiceUUID : matterServiceData } }];
+}
+
+- (void)removeMockPeripheralWithIdentifier:(NSUUID *)identifier
+{
+    [self sync:^(BOOL isValid) {
+        if (isValid) {
+            _peripherals[identifier] = nil;
+        }
+    }];
+}
+
+@end
+
+@implementation MTRMockCBPeripheralDetails
+
+- (instancetype)initWithIdentifier:(NSUUID *)identifier
+{
+    self = [super init];
+    _identifier = identifier;
+    return self;
+}
+
+- (NSString *)name
+{
+    return _identifier.UUIDString;
+}
+
+- (NSDictionary<NSString *, id> *)advertisementData
+{
+    NSMutableDictionary * data = [[NSMutableDictionary alloc] init];
+    data[CBAdvertisementDataLocalNameKey] = self.name;
+    data[CBAdvertisementDataServiceUUIDsKey] = self.services;
+    data[CBAdvertisementDataIsConnectable] = @YES;
+    if (_extraAdvertisementData) {
+        [data addEntriesFromDictionary:_extraAdvertisementData];
+    }
+    return [data copy];
+}
+
+- (BOOL)matchesAnyServices:(nullable NSArray<CBUUID *> *)serviceUUIDs
+{
+    if (!serviceUUIDs) {
+        return YES; // special case
+    }
+    for (CBUUID * expected in serviceUUIDs) {
+        if ([self.services containsObject:expected]) {
+            return YES;
+        }
+    }
+    return NO;
+}
+
+@end
+
+@implementation MTRMockCBCentralManager {
+@package
+    MTRMockCB * _mock; // retain cycle (broken by stopMocking)
+    BOOL _initialized;
+
+    id<CBCentralManagerDelegate> __weak _Nullable _delegate;
+    dispatch_queue_t _delegateQueue;
+    NSDictionary<NSString *, id> * _Nullable _options;
+
+    NSArray<CBUUID *> * _Nullable _scanServiceUUIDs;
+    NSDictionary<NSString *, id> * _Nullable _scanOptions;
+}
+
+- (instancetype)_initWithMock:(MTRMockCB *)mock
+{
+    self = [super init];
+    _mock = mock;
+    return self;
+}
+
+- (BOOL)isKindOfClass:(Class)aClass
+{
+    return [super isKindOfClass:aClass] || aClass == CBManager.class || aClass == CBCentralManager.class;
+}
+
+- (NSString *)description
+{
+    __block NSString * result;
+    [_mock sync:^(BOOL isValid) {
+        result = [NSString stringWithFormat:@"<%@ %p %@ %@>",
+                           self.class, self, CBManagerStateAsString(self.state), isValid ? @"valid" : @"defunct"];
+    }];
+    return result;
+}
+
+// MARK: CBManager
+
++ (CBManagerAuthorization)authorization
+{
+    return CBManagerAuthorizationAllowedAlways;
+}
+
+- (CBManagerAuthorization)authorization
+{
+    return [[self class] authorization];
+}
+
+- (CBManagerState)state
+{
+    return _mock.state;
+}
+
+// MARK: CBCentralManager
+
++ (BOOL)supportsFeatures:(CBCentralManagerFeature)features
+{
+    return NO;
+}
+
+- (instancetype)init
+{
+    return [self initWithDelegate:nil queue:nil options:nil];
+}
+
+- (instancetype)initWithDelegate:(nullable id<CBCentralManagerDelegate>)delegate
+                           queue:(nullable dispatch_queue_t)queue
+{
+    return [self initWithDelegate:delegate queue:queue options:nil];
+}
+
+- (instancetype)initWithDelegate:(nullable id<CBCentralManagerDelegate>)delegate
+                           queue:(nullable dispatch_queue_t)queue
+                         options:(nullable NSDictionary<NSString *, id> *)options
+{
+    XCTAssertFalse(_initialized);
+    _initialized = YES;
+
+    _delegate = delegate;
+    _delegateQueue = queue ?: dispatch_get_main_queue();
+    _options = options;
+
+    [_mock sync:^(BOOL isValid) {
+        if (isValid) {
+            [_mock->_managers addObject:self];
+            [self _didUpdateState];
+        }
+    }];
+    return self;
+}
+
+- (NSArray<CBPeripheral *> *)retrievePeripheralsWithIdentifiers:(NSArray<NSUUID *> *)identifiers
+{
+    return nil;
+}
+
+- (NSArray<CBPeripheral *> *)retrieveConnectedPeripheralsWithServices:(NSArray<CBUUID *> *)serviceUUIDs
+{
+    return nil;
+}
+
+- (void)scanForPeripheralsWithServices:(nullable NSArray<CBUUID *> *)serviceUUIDs
+                               options:(nullable NSDictionary<NSString *, id> *)options
+{
+    [_mock sync:^(BOOL isValid) {
+        _scanServiceUUIDs = [serviceUUIDs copy];
+        _scanOptions = [options copy];
+        _isScanning = YES;
+
+        if (isValid) {
+            __auto_type callback = _mock.onScanForPeripheralsWithServicesOptions;
+            if (callback) {
+                callback(serviceUUIDs, options);
+            }
+
+            for (MTRMockCBPeripheralDetails * details in _mock->_peripherals.allValues) {
+                [self _maybeDiscoverPeripheral:details];
+            }
+        }
+    }];
+}
+
+- (void)stopScan
+{
+    [_mock sync:^(BOOL isValid) {
+        _scanServiceUUIDs = nil;
+        _scanOptions = nil;
+        _isScanning = NO;
+
+        if (isValid) {
+            __auto_type callback = _mock.onStopScan;
+            if (callback) {
+                callback();
+            }
+        }
+    }];
+}
+
+- (void)connectPeripheral:(CBPeripheral *)peripheral options:(nullable NSDictionary<NSString *, id> *)options
+{
+}
+
+- (void)cancelPeripheralConnection:(CBPeripheral *)peripheral
+{
+}
+
+- (void)registerForConnectionEventsWithOptions:(nullable NSDictionary<CBConnectionEventMatchingOption, id> *)options
+{
+}
+
+// MARK: Internals
+
+- (void)_didUpdateState
+{
+    os_log(_mock->_log, "%@ didUpdateState", self);
+    dispatch_async(_delegateQueue, ^{
+        [self->_delegate centralManagerDidUpdateState:(id) self];
+    });
+}
+
+- (void)_maybeDiscoverPeripheral:(MTRMockCBPeripheralDetails *)details
+{
+    if (_isScanning && [details matchesAnyServices:_scanServiceUUIDs] &&
+        [_delegate respondsToSelector:@selector(centralManager:didDiscoverPeripheral:advertisementData:RSSI:)]) {
+        // TODO: Cache CBPeripheral mocks as long as the client keeps them alive?
+        MTRMockCBPeripheral * peripheral = [[MTRMockCBPeripheral alloc] _initWithDetails:details manager:self];
+        NSDictionary<NSString *, id> * advertisementData = details.advertisementData;
+        os_log(_mock->_log, "%@ didDiscoverPeripheral %@", self, peripheral);
+        dispatch_async(_delegateQueue, ^{
+            [self->_delegate centralManager:(id) self
+                      didDiscoverPeripheral:(id) peripheral
+                          advertisementData:advertisementData
+                                       RSSI:@127 /* Reserved for "RSSI not available" */];
+        });
+    }
+}
+
+@end
+
+@implementation MTRMockCBPeripheral {
+    MTRMockCBPeripheralDetails * _details;
+    MTRMockCBCentralManager * _manager;
+}
+
+- (instancetype)_initWithDetails:(MTRMockCBPeripheralDetails *)details manager:(MTRMockCBCentralManager *)manager
+{
+    self = [super init];
+    _details = details;
+    _manager = manager;
+    return self;
+}
+
+- (BOOL)isKindOfClass:(Class)aClass
+{
+    return [super isKindOfClass:aClass] || aClass == CBPeer.class || aClass == CBPeripheral.class;
+}
+
+- (NSString *)description
+{
+    __block NSString * result;
+    [_manager->_mock sync:^(BOOL isValid) {
+        result = [NSString stringWithFormat:@"<%@ %p %@ %@>", self.class, self, self.identifier, isValid ? @"valid" : @"defunct"];
+    }];
+    return result;
+}
+
+// MARK: CBPeer <NSCopying>
+
+- (BOOL)isEqual:(id)object
+{
+    return [object class] == [self class] && [((MTRMockCBPeripheral *) object).identifier isEqualTo:self.identifier];
+}
+
+- (NSUInteger)hash
+{
+    return self.identifier.hash;
+}
+
+- (id)copyWithZone:(nullable NSZone *)zone
+{
+    return self; // identifier is not mutable (and this is what CBPeer does)
+}
+
+- (NSUUID *)identifier
+{
+    return _details.identifier;
+}
+
+// MARK: CBPeripheral
+
+- (nullable NSString *)name
+{
+    return _details.identifier.UUIDString;
+}
+
+- (nullable NSNumber *)RSSI
+{
+    return nil;
+}
+
+- (CBPeripheralState)state
+{
+    return _details.state;
+}
+
+- (nullable NSArray<CBService *> *)services
+{
+    return nil;
+}
+
+- (void)readRSSI
+{
+}
+
+- (void)discoverServices:(nullable NSArray<CBUUID *> *)serviceUUIDs
+{
+}
+
+- (void)discoverIncludedServices:(nullable NSArray<CBUUID *> *)includedServiceUUIDs forService:(CBService *)service
+{
+}
+
+- (void)discoverCharacteristics:(nullable NSArray<CBUUID *> *)characteristicUUIDs forService:(CBService *)service
+{
+}
+
+- (void)readValueForCharacteristic:(CBCharacteristic *)characteristic
+{
+}
+
+- (NSUInteger)maximumWriteValueLengthForType:(CBCharacteristicWriteType)type
+{
+    return 512;
+}
+
+- (void)writeValue:(NSData *)data forCharacteristic:(CBCharacteristic *)characteristic type:(CBCharacteristicWriteType)type
+{
+}
+
+- (void)setNotifyValue:(BOOL)enabled forCharacteristic:(CBCharacteristic *)characteristic
+{
+}
+
+- (void)discoverDescriptorsForCharacteristic:(CBCharacteristic *)characteristic
+{
+}
+
+- (void)readValueForDescriptor:(CBDescriptor *)descriptor
+{
+}
+
+- (void)writeValue:(NSData *)data forDescriptor:(CBDescriptor *)descriptor
+{
+}
+
+- (void)openL2CAPChannel:(CBL2CAPPSM)PSM
+{
+}
+
+@end
+
+NS_ASSUME_NONNULL_END
diff --git a/src/darwin/Framework/CHIPTests/TestHelpers/MTRTestCase.h b/src/darwin/Framework/CHIPTests/TestHelpers/MTRTestCase.h
index 3b404f6f05b1f4..d698ea8a959e16 100644
--- a/src/darwin/Framework/CHIPTests/TestHelpers/MTRTestCase.h
+++ b/src/darwin/Framework/CHIPTests/TestHelpers/MTRTestCase.h
@@ -22,14 +22,27 @@
 #define HAVE_NSTASK 1
 #endif
 
+@class MTRDeviceController;
+@class MTRMockCB;
+
 NS_ASSUME_NONNULL_BEGIN
 
 @interface MTRTestCase : XCTestCase
+
 // It would be nice to do the leak-detection automatically, but running "leaks"
 // on every single sub-test is slow, and some of our tests seem to have leaks
 // outside Matter.framework.  So have it be opt-in for now, and improve later.
 @property (nonatomic) BOOL detectLeaks;
 
+// Creates a device controller on a new fabric with test keys and test storage.
++ (MTRDeviceController *)createControllerOnTestFabric;
+
+// Provides access to the mock CoreBlueooth instance managed automatically by
+// this class. Bluetooth mocking is enabled for all tests (even those that don't
+// actively interact with it) to avoid issues with accessing the real Bluetooth
+// implementation in CI.
+@property (class, readonly) MTRMockCB * mockCoreBluetooth;
+
 #if HAVE_NSTASK
 /**
  * Create an NSTask for the given path.  Path should be relative to the Matter
diff --git a/src/darwin/Framework/CHIPTests/TestHelpers/MTRTestCase.mm b/src/darwin/Framework/CHIPTests/TestHelpers/MTRTestCase.mm
index 915df782be4f43..02be960cc2b0a7 100644
--- a/src/darwin/Framework/CHIPTests/TestHelpers/MTRTestCase.mm
+++ b/src/darwin/Framework/CHIPTests/TestHelpers/MTRTestCase.mm
@@ -14,14 +14,20 @@
  *    limitations under the License.
  */
 
+#import "MTRTestCase.h"
+
+#import "MTRMockCB.h"
+#import "MTRTestKeys.h"
+#import "MTRTestStorage.h"
+
 #include <stdlib.h>
 #include <unistd.h>
 
-#import "MTRTestCase.h"
-
 #if HAVE_NSTASK
 // Tasks that are not scoped to a specific test, but rather to a specific test suite.
-static NSMutableSet<NSTask *> * runningCrossTestTasks;
+static NSMutableSet<NSTask *> * sRunningCrossTestTasks;
+
+static MTRMockCB * sMockCB;
 
 static void ClearTaskSet(NSMutableSet<NSTask *> * __strong & tasks)
 {
@@ -43,16 +49,26 @@ + (void)setUp
 {
     [super setUp];
 
+    sMockCB = [[MTRMockCB alloc] init];
+
 #if HAVE_NSTASK
-    runningCrossTestTasks = [[NSMutableSet alloc] init];
+    sRunningCrossTestTasks = [[NSMutableSet alloc] init];
 #endif // HAVE_NSTASK
 }
 
 + (void)tearDown
 {
 #if HAVE_NSTASK
-    ClearTaskSet(runningCrossTestTasks);
+    ClearTaskSet(sRunningCrossTestTasks);
 #endif // HAVE_NSTASK
+
+    [sMockCB stopMocking];
+    sMockCB = nil;
+}
+
++ (MTRMockCB *)mockCoreBluetooth
+{
+    return sMockCB;
 }
 
 - (void)setUp
@@ -106,6 +122,22 @@ - (void)tearDown
     [super tearDown];
 }
 
++ (id)createControllerOnTestFabric
+{
+    __auto_type * storage = [[MTRTestStorage alloc] init];
+    __auto_type * factoryParams = [[MTRDeviceControllerFactoryParams alloc] initWithStorage:storage];
+    __auto_type * factory = MTRDeviceControllerFactory.sharedInstance;
+    XCTAssertTrue([factory startControllerFactory:factoryParams error:nil]);
+
+    __auto_type * testKeys = [[MTRTestKeys alloc] init];
+    __auto_type * params = [[MTRDeviceControllerStartupParams alloc] initWithIPK:testKeys.ipk fabricID:@1 nocSigner:testKeys];
+    params.vendorID = @0xFFF1;
+    MTRDeviceController * controller = [factory createControllerOnNewFabric:params error:nil];
+    XCTAssertNotNil(controller);
+
+    return controller;
+}
+
 #if HAVE_NSTASK
 - (NSTask *)createTaskForPath:(NSString *)path
 {
@@ -147,7 +179,7 @@ + (void)launchTask:(NSTask *)task
 {
     [self doLaunchTask:task];
 
-    [runningCrossTestTasks addObject:task];
+    [sRunningCrossTestTasks addObject:task];
 }
 #endif // HAVE_NSTASK
 
diff --git a/src/darwin/Framework/Matter.xcodeproj/project.pbxproj b/src/darwin/Framework/Matter.xcodeproj/project.pbxproj
index 940b1eb2fbc55c..6bdeae34c6ff46 100644
--- a/src/darwin/Framework/Matter.xcodeproj/project.pbxproj
+++ b/src/darwin/Framework/Matter.xcodeproj/project.pbxproj
@@ -132,10 +132,12 @@
 		3DA1A3552ABAB3B4004F0BB9 /* MTRAsyncWorkQueue.h in Headers */ = {isa = PBXBuildFile; fileRef = 3DA1A3522ABAB3B4004F0BB9 /* MTRAsyncWorkQueue.h */; };
 		3DA1A3562ABAB3B4004F0BB9 /* MTRAsyncWorkQueue.mm in Sources */ = {isa = PBXBuildFile; fileRef = 3DA1A3532ABAB3B4004F0BB9 /* MTRAsyncWorkQueue.mm */; };
 		3DA1A3582ABABF6A004F0BB9 /* MTRAsyncWorkQueueTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 3DA1A3572ABABF69004F0BB9 /* MTRAsyncWorkQueueTests.m */; };
+		3DB9DAE52D67EE5A00704FAB /* MTRBleTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 3DB9DAE42D67EE5A00704FAB /* MTRBleTests.m */; };
 		3DECCB6E29347D2D00585AEC /* Security.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3DECCB6D29347D2C00585AEC /* Security.framework */; };
 		3DECCB702934AECD00585AEC /* MTRLogging.h in Headers */ = {isa = PBXBuildFile; fileRef = 3DECCB6F2934AC1C00585AEC /* MTRLogging.h */; settings = {ATTRIBUTES = (Public, ); }; };
 		3DECCB722934AFE200585AEC /* MTRLogging.mm in Sources */ = {isa = PBXBuildFile; fileRef = 3DECCB712934AFE200585AEC /* MTRLogging.mm */; };
 		3DECCB742934C21B00585AEC /* MTRDefines.h in Headers */ = {isa = PBXBuildFile; fileRef = 3DECCB732934C21B00585AEC /* MTRDefines.h */; settings = {ATTRIBUTES = (Public, ); }; };
+		3DF5219E2D62C3E5008F8E52 /* MTRMockCB.m in Sources */ = {isa = PBXBuildFile; fileRef = 3DF5219D2D62C3E5008F8E52 /* MTRMockCB.m */; };
 		3DFCB3292966684500332B35 /* MTRCertificateInfoTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 3DFCB3282966684500332B35 /* MTRCertificateInfoTests.m */; };
 		3DFCB32C29678C9500332B35 /* MTRConversion.h in Headers */ = {isa = PBXBuildFile; fileRef = 3DFCB32B29678C9500332B35 /* MTRConversion.h */; };
 		51029DF6293AA6100087AFB0 /* MTROperationalCertificateIssuer.mm in Sources */ = {isa = PBXBuildFile; fileRef = 51029DF5293AA6100087AFB0 /* MTROperationalCertificateIssuer.mm */; };
@@ -653,18 +655,21 @@
 		3DA1A3522ABAB3B4004F0BB9 /* MTRAsyncWorkQueue.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = MTRAsyncWorkQueue.h; sourceTree = "<group>"; };
 		3DA1A3532ABAB3B4004F0BB9 /* MTRAsyncWorkQueue.mm */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.objcpp; path = MTRAsyncWorkQueue.mm; sourceTree = "<group>"; };
 		3DA1A3572ABABF69004F0BB9 /* MTRAsyncWorkQueueTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = MTRAsyncWorkQueueTests.m; sourceTree = "<group>"; };
+		3DB9DAE42D67EE5A00704FAB /* MTRBleTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MTRBleTests.m; sourceTree = "<group>"; };
+		3DB9DAE92D754C7200704FAB /* BleUtils.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = BleUtils.h; sourceTree = "<group>"; };
+		3DB9DAEA2D754C7200704FAB /* BleUtils.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = BleUtils.mm; sourceTree = "<group>"; };
 		3DECCB6D29347D2C00585AEC /* Security.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Security.framework; path = Platforms/MacOSX.platform/Developer/SDKs/MacOSX13.1.sdk/System/Library/Frameworks/Security.framework; sourceTree = DEVELOPER_DIR; };
 		3DECCB6F2934AC1C00585AEC /* MTRLogging.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MTRLogging.h; sourceTree = "<group>"; };
 		3DECCB712934AFE200585AEC /* MTRLogging.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = MTRLogging.mm; sourceTree = "<group>"; };
 		3DECCB732934C21B00585AEC /* MTRDefines.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MTRDefines.h; sourceTree = "<group>"; };
-		3DF521682D5E90BE008F8E52 /* BleApplicationDelegate.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = BleApplicationDelegate.h; sourceTree = "<group>"; };
+		3DF521682D5E90BE008F8E52 /* BleApplicationDelegateImpl.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = BleApplicationDelegateImpl.h; sourceTree = "<group>"; };
 		3DF521692D5E90BE008F8E52 /* BleApplicationDelegateImpl.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = BleApplicationDelegateImpl.mm; sourceTree = "<group>"; };
-		3DF5216A2D5E90BE008F8E52 /* BleConnectionDelegate.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = BleConnectionDelegate.h; sourceTree = "<group>"; };
+		3DF5216A2D5E90BE008F8E52 /* BleConnectionDelegateImpl.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = BleConnectionDelegateImpl.h; sourceTree = "<group>"; };
 		3DF5216B2D5E90BE008F8E52 /* BleConnectionDelegateImpl.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = BleConnectionDelegateImpl.mm; sourceTree = "<group>"; };
 		3DF5216C2D5E90BE008F8E52 /* BLEManagerImpl.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = BLEManagerImpl.h; sourceTree = "<group>"; };
 		3DF5216D2D5E90BE008F8E52 /* BLEManagerImpl.cpp */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.cpp; path = BLEManagerImpl.cpp; sourceTree = "<group>"; };
 		3DF5216E2D5E90BE008F8E52 /* BlePlatformConfig.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = BlePlatformConfig.h; sourceTree = "<group>"; };
-		3DF5216F2D5E90BE008F8E52 /* BlePlatformDelegate.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = BlePlatformDelegate.h; sourceTree = "<group>"; };
+		3DF5216F2D5E90BE008F8E52 /* BlePlatformDelegateImpl.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = BlePlatformDelegateImpl.h; sourceTree = "<group>"; };
 		3DF521702D5E90BE008F8E52 /* BlePlatformDelegateImpl.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = BlePlatformDelegateImpl.mm; sourceTree = "<group>"; };
 		3DF521712D5E90BE008F8E52 /* BleScannerDelegate.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = BleScannerDelegate.h; sourceTree = "<group>"; };
 		3DF521722D5E90BE008F8E52 /* BUILD.gn */ = {isa = PBXFileReference; lastKnownFileType = text; path = BUILD.gn; sourceTree = "<group>"; };
@@ -695,8 +700,6 @@
 		3DF5218B2D5E90BE008F8E52 /* Logging.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = Logging.mm; sourceTree = "<group>"; };
 		3DF5218C2D5E90BE008F8E52 /* MdnsError.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MdnsError.h; sourceTree = "<group>"; };
 		3DF5218D2D5E90BE008F8E52 /* MdnsError.cpp */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.cpp; path = MdnsError.cpp; sourceTree = "<group>"; };
-		3DF5218E2D5E90BE008F8E52 /* MTRUUIDHelper.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MTRUUIDHelper.h; sourceTree = "<group>"; };
-		3DF5218F2D5E90BE008F8E52 /* MTRUUIDHelperImpl.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = MTRUUIDHelperImpl.mm; sourceTree = "<group>"; };
 		3DF521902D5E90BE008F8E52 /* NetworkCommissioningDriver.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = NetworkCommissioningDriver.h; sourceTree = "<group>"; };
 		3DF521912D5E90BE008F8E52 /* PlatformManagerImpl.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = PlatformManagerImpl.h; sourceTree = "<group>"; };
 		3DF521922D5E90BE008F8E52 /* PlatformManagerImpl.cpp */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.cpp; path = PlatformManagerImpl.cpp; sourceTree = "<group>"; };
@@ -709,6 +712,8 @@
 		3DF521992D5E90BE008F8E52 /* Tracing.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = Tracing.mm; sourceTree = "<group>"; };
 		3DF5219A2D5E90BE008F8E52 /* UserDefaults.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = UserDefaults.h; sourceTree = "<group>"; };
 		3DF5219B2D5E90BE008F8E52 /* UserDefaults.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = UserDefaults.mm; sourceTree = "<group>"; };
+		3DF5219C2D62C3E5008F8E52 /* MTRMockCB.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MTRMockCB.h; sourceTree = "<group>"; };
+		3DF5219D2D62C3E5008F8E52 /* MTRMockCB.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MTRMockCB.m; sourceTree = "<group>"; };
 		3DFCB3282966684500332B35 /* MTRCertificateInfoTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MTRCertificateInfoTests.m; sourceTree = "<group>"; };
 		3DFCB32A2966827F00332B35 /* MTRDefines_Internal.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MTRDefines_Internal.h; sourceTree = "<group>"; };
 		3DFCB32B29678C9500332B35 /* MTRConversion.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MTRConversion.h; sourceTree = "<group>"; };
@@ -1400,16 +1405,18 @@
 			isa = PBXGroup;
 			children = (
 				3DF521722D5E90BE008F8E52 /* BUILD.gn */,
-				3DF521682D5E90BE008F8E52 /* BleApplicationDelegate.h */,
+				3DF521682D5E90BE008F8E52 /* BleApplicationDelegateImpl.h */,
 				3DF521692D5E90BE008F8E52 /* BleApplicationDelegateImpl.mm */,
-				3DF5216A2D5E90BE008F8E52 /* BleConnectionDelegate.h */,
+				3DF5216A2D5E90BE008F8E52 /* BleConnectionDelegateImpl.h */,
 				3DF5216B2D5E90BE008F8E52 /* BleConnectionDelegateImpl.mm */,
 				3DF5216C2D5E90BE008F8E52 /* BLEManagerImpl.h */,
 				3DF5216D2D5E90BE008F8E52 /* BLEManagerImpl.cpp */,
 				3DF5216E2D5E90BE008F8E52 /* BlePlatformConfig.h */,
-				3DF5216F2D5E90BE008F8E52 /* BlePlatformDelegate.h */,
+				3DF5216F2D5E90BE008F8E52 /* BlePlatformDelegateImpl.h */,
 				3DF521702D5E90BE008F8E52 /* BlePlatformDelegateImpl.mm */,
 				3DF521712D5E90BE008F8E52 /* BleScannerDelegate.h */,
+				3DB9DAE92D754C7200704FAB /* BleUtils.h */,
+				3DB9DAEA2D754C7200704FAB /* BleUtils.mm */,
 				3DF521732D5E90BE008F8E52 /* CHIPDevicePlatformConfig.h */,
 				3DF521742D5E90BE008F8E52 /* CHIPDevicePlatformEvent.h */,
 				3DF521752D5E90BE008F8E52 /* CHIPPlatformConfig.h */,
@@ -1437,8 +1444,6 @@
 				3DF5218B2D5E90BE008F8E52 /* Logging.mm */,
 				3DF5218C2D5E90BE008F8E52 /* MdnsError.h */,
 				3DF5218D2D5E90BE008F8E52 /* MdnsError.cpp */,
-				3DF5218E2D5E90BE008F8E52 /* MTRUUIDHelper.h */,
-				3DF5218F2D5E90BE008F8E52 /* MTRUUIDHelperImpl.mm */,
 				3DF521902D5E90BE008F8E52 /* NetworkCommissioningDriver.h */,
 				3DF521912D5E90BE008F8E52 /* PlatformManagerImpl.h */,
 				3DF521922D5E90BE008F8E52 /* PlatformManagerImpl.cpp */,
@@ -1480,6 +1485,8 @@
 				75B0D01C2B71B46F002074DD /* MTRDeviceTestDelegate.h */,
 				75B0D01D2B71B47F002074DD /* MTRDeviceTestDelegate.m */,
 				75139A6C2B7FE19100E3A919 /* MTRTestDeclarations.h */,
+				3DF5219C2D62C3E5008F8E52 /* MTRMockCB.h */,
+				3DF5219D2D62C3E5008F8E52 /* MTRMockCB.m */,
 			);
 			path = TestHelpers;
 			sourceTree = "<group>";
@@ -1796,6 +1803,7 @@
 				3DA1A3572ABABF69004F0BB9 /* MTRAsyncWorkQueueTests.m */,
 				3D3928D62BBCEA3D00CDEBB2 /* MTRAvailabilityTests.m */,
 				51669AEF2913204400F4AA36 /* MTRBackwardsCompatTests.m */,
+				3DB9DAE42D67EE5A00704FAB /* MTRBleTests.m */,
 				3DFCB3282966684500332B35 /* MTRCertificateInfoTests.m */,
 				517BF3F2282B62CB00A8B7DB /* MTRCertificateTests.m */,
 				51339B1E2A0DA64D00C798C1 /* MTRCertificateValidityTests.m */,
@@ -2594,6 +2602,7 @@
 				8874C1322B69C7060084BEFD /* MTRMetricsTests.m in Sources */,
 				1E5801C328941C050033A199 /* MTRTestOTAProvider.m in Sources */,
 				5A6FEC9D27B5E48900F25F42 /* MTRXPCProtocolTests.m in Sources */,
+				3DB9DAE52D67EE5A00704FAB /* MTRBleTests.m in Sources */,
 				1EE0805E2A44875E008A03C2 /* MTRCommissionableBrowserTests.m in Sources */,
 				518D3F832AA132DC008E0007 /* MTRTestPerControllerStorage.m in Sources */,
 				51339B1F2A0DA64D00C798C1 /* MTRCertificateValidityTests.m in Sources */,
@@ -2605,6 +2614,7 @@
 				75B0D01E2B71B47F002074DD /* MTRDeviceTestDelegate.m in Sources */,
 				3D0C484B29DA4FA0006D811F /* MTRErrorTests.m in Sources */,
 				3DA1A3582ABABF6A004F0BB9 /* MTRAsyncWorkQueueTests.m in Sources */,
+				3DF5219E2D62C3E5008F8E52 /* MTRMockCB.m in Sources */,
 				51742B4A29CB5FC1009974FE /* MTRTestResetCommissioneeHelper.m in Sources */,
 				5AE6D4E427A99041001F2493 /* MTRDeviceTests.m in Sources */,
 				510CECA8297F72970064E0B3 /* MTROperationalCertificateIssuerTests.m in Sources */,
diff --git a/src/platform/Darwin/BLEManagerImpl.cpp b/src/platform/Darwin/BLEManagerImpl.cpp
index 8ff634c52cb1ed..080d75ed1a861b 100644
--- a/src/platform/Darwin/BLEManagerImpl.cpp
+++ b/src/platform/Darwin/BLEManagerImpl.cpp
@@ -1,6 +1,6 @@
 /*
  *
- *    Copyright (c) 2020 Project CHIP Authors
+ *    Copyright (c) 2020-2025 Project CHIP Authors
  *    Copyright (c) 2018 Nest Labs, Inc.
  *
  *    Licensed under the Apache License, Version 2.0 (the "License");
@@ -26,9 +26,9 @@
 #include <ble/Ble.h>
 #include <lib/core/Global.h>
 #include <lib/support/logging/CHIPLogging.h>
-#include <platform/Darwin/BleApplicationDelegate.h>
-#include <platform/Darwin/BleConnectionDelegate.h>
-#include <platform/Darwin/BlePlatformDelegate.h>
+#include <platform/Darwin/BleApplicationDelegateImpl.h>
+#include <platform/Darwin/BleConnectionDelegateImpl.h>
+#include <platform/Darwin/BlePlatformDelegateImpl.h>
 
 #if CHIP_DEVICE_CONFIG_ENABLE_CHIPOBLE
 
@@ -43,61 +43,29 @@ Global<BLEManagerImpl> BLEManagerImpl::sInstance;
 
 CHIP_ERROR BLEManagerImpl::_Init()
 {
-    CHIP_ERROR err;
+    ChipLogDetail(DeviceLayer, "Initializing BLE Manager");
 
-    ChipLogDetail(DeviceLayer, "%s", __FUNCTION__);
-
-    // Initialize the Chip BleLayer.
-    BleApplicationDelegateImpl * appDelegate   = new BleApplicationDelegateImpl();
-    BleConnectionDelegateImpl * connDelegate   = new BleConnectionDelegateImpl();
-    BlePlatformDelegateImpl * platformDelegate = new BlePlatformDelegateImpl();
-
-    mApplicationDelegate = appDelegate;
-    mConnectionDelegate  = connDelegate;
-    mPlatformDelegate    = platformDelegate;
-
-    err = BleLayer::Init(platformDelegate, connDelegate, appDelegate, &DeviceLayer::SystemLayer());
-
-    if (CHIP_NO_ERROR != err)
-    {
-        _Shutdown();
-    }
-
-    return err;
+    // Initialize the CHIP BleLayer. The application, connection, and platform delegate
+    // implementations are all stateless classes that we inherit from privately.
+    return BleLayer::Init(this, this, this, &DeviceLayer::SystemLayer());
 }
 
 void BLEManagerImpl::_Shutdown()
 {
-    if (mApplicationDelegate)
-    {
-        delete mApplicationDelegate;
-        mApplicationDelegate = nullptr;
-    }
-
-    if (mConnectionDelegate)
-    {
-        delete mConnectionDelegate;
-        mConnectionDelegate = nullptr;
-    }
-
-    if (mPlatformDelegate)
-    {
-        delete mPlatformDelegate;
-        mPlatformDelegate = nullptr;
-    }
+    // Nothing to do
 }
 
 CHIP_ERROR BLEManagerImpl::StartScan(BleScannerDelegate * delegate, BleScanMode mode)
 {
-    VerifyOrReturnError(mConnectionDelegate != nullptr, CHIP_ERROR_INCORRECT_STATE);
-    static_cast<BleConnectionDelegateImpl *>(mConnectionDelegate)->StartScan(delegate, mode);
+    VerifyOrReturnError(BleLayer::IsInitialized(), CHIP_ERROR_INCORRECT_STATE);
+    BleConnectionDelegateImpl::StartScan(delegate, mode);
     return CHIP_NO_ERROR;
 }
 
 CHIP_ERROR BLEManagerImpl::StopScan()
 {
-    VerifyOrReturnError(mConnectionDelegate != nullptr, CHIP_ERROR_INCORRECT_STATE);
-    static_cast<BleConnectionDelegateImpl *>(mConnectionDelegate)->StopScan();
+    VerifyOrReturnError(BleLayer::IsInitialized(), CHIP_ERROR_INCORRECT_STATE);
+    BleConnectionDelegateImpl::StopScan();
     return CHIP_NO_ERROR;
 }
 
diff --git a/src/platform/Darwin/BLEManagerImpl.h b/src/platform/Darwin/BLEManagerImpl.h
index 194dadb2e79529..48439b9b21cf4d 100644
--- a/src/platform/Darwin/BLEManagerImpl.h
+++ b/src/platform/Darwin/BLEManagerImpl.h
@@ -1,6 +1,6 @@
 /*
  *
- *    Copyright (c) 2020 Project CHIP Authors
+ *    Copyright (c) 2020-2025 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.
@@ -23,8 +23,12 @@
 
 #pragma once
 
+#include <ble/Ble.h>
 #include <lib/core/Global.h>
 #include <lib/support/CodeUtils.h>
+#include <platform/Darwin/BleApplicationDelegateImpl.h>
+#include <platform/Darwin/BleConnectionDelegateImpl.h>
+#include <platform/Darwin/BlePlatformDelegateImpl.h>
 #include <platform/Darwin/BleScannerDelegate.h>
 
 #if CHIP_DEVICE_CONFIG_ENABLE_CHIPOBLE
@@ -38,14 +42,18 @@ using namespace chip::Ble;
 /**
  * Concrete implementation of the BLEManagerImpl singleton object for the Darwin platforms.
  */
-class BLEManagerImpl final : public BLEManager, private BleLayer
+class BLEManagerImpl final : public BLEManager,
+                             private BleLayer,
+                             private BleApplicationDelegateImpl,
+                             private BleConnectionDelegateImpl,
+                             private BlePlatformDelegateImpl
 {
     // Allow the BLEManager interface class to delegate method calls to
     // the implementation methods provided by this class.
     friend BLEManager;
 
 public:
-    CHIP_ERROR ConfigureBle(uint32_t aNodeId, bool aIsCentral) { return CHIP_NO_ERROR; }
+    CHIP_ERROR ConfigureBle(uint32_t bleDeviceId, bool aIsCentral) { return CHIP_NO_ERROR; }
     CHIP_ERROR StartScan(BleScannerDelegate * delegate, BleScanMode mode = BleScanMode::kDefault);
     CHIP_ERROR StopScan();
 
@@ -70,10 +78,6 @@ class BLEManagerImpl final : public BLEManager, private BleLayer
     friend BLEManagerImpl & BLEMgrImpl(void);
 
     static Global<BLEManagerImpl> sInstance;
-
-    BleConnectionDelegate * mConnectionDelegate   = nullptr;
-    BlePlatformDelegate * mPlatformDelegate       = nullptr;
-    BleApplicationDelegate * mApplicationDelegate = nullptr;
 };
 
 /**
diff --git a/src/platform/Darwin/BUILD.gn b/src/platform/Darwin/BUILD.gn
index e263c8f398c495..57ad9c7821db33 100644
--- a/src/platform/Darwin/BUILD.gn
+++ b/src/platform/Darwin/BUILD.gn
@@ -124,14 +124,14 @@ static_library("Darwin") {
 
   if (chip_enable_ble) {
     sources += [
-      "BleApplicationDelegate.h",
+      "BleApplicationDelegateImpl.h",
       "BleApplicationDelegateImpl.mm",
-      "BleConnectionDelegate.h",
+      "BleConnectionDelegateImpl.h",
       "BleConnectionDelegateImpl.mm",
-      "BlePlatformDelegate.h",
+      "BlePlatformDelegateImpl.h",
       "BlePlatformDelegateImpl.mm",
-      "MTRUUIDHelper.h",
-      "MTRUUIDHelperImpl.mm",
+      "BleUtils.h",
+      "BleUtils.mm",
     ]
   }
 }
diff --git a/src/platform/Darwin/BleApplicationDelegate.h b/src/platform/Darwin/BleApplicationDelegateImpl.h
similarity index 86%
rename from src/platform/Darwin/BleApplicationDelegate.h
rename to src/platform/Darwin/BleApplicationDelegateImpl.h
index 72aba4e65b397b..e9d985ab0e80c8 100644
--- a/src/platform/Darwin/BleApplicationDelegate.h
+++ b/src/platform/Darwin/BleApplicationDelegateImpl.h
@@ -1,6 +1,6 @@
 /*
  *
- *    Copyright (c) 2020 Project CHIP Authors
+ *    Copyright (c) 2020-2025 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.
@@ -26,7 +26,7 @@ namespace Internal {
 class BleApplicationDelegateImpl : public Ble::BleApplicationDelegate
 {
 public:
-    virtual void NotifyChipConnectionClosed(BLE_CONNECTION_OBJECT connObj);
+    void NotifyChipConnectionClosed(BLE_CONNECTION_OBJECT connObj) override;
 };
 
 } // namespace Internal
diff --git a/src/platform/Darwin/BleApplicationDelegateImpl.mm b/src/platform/Darwin/BleApplicationDelegateImpl.mm
index 883f2a2bcd8916..01fc44e46581ee 100644
--- a/src/platform/Darwin/BleApplicationDelegateImpl.mm
+++ b/src/platform/Darwin/BleApplicationDelegateImpl.mm
@@ -1,6 +1,6 @@
 /*
  *
- *    Copyright (c) 2020 Project CHIP Authors
+ *    Copyright (c) 2020-2025 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.
@@ -25,10 +25,7 @@
 #endif
 
 #include <ble/Ble.h>
-#include <platform/Darwin/BleApplicationDelegate.h>
-
-using namespace ::chip;
-using namespace ::chip::Ble;
+#include <platform/Darwin/BleApplicationDelegateImpl.h>
 
 namespace chip {
 namespace DeviceLayer {
diff --git a/src/platform/Darwin/BleConnectionDelegate.h b/src/platform/Darwin/BleConnectionDelegateImpl.h
similarity index 96%
rename from src/platform/Darwin/BleConnectionDelegate.h
rename to src/platform/Darwin/BleConnectionDelegateImpl.h
index 585624ae407dfa..bb0afd4df64cde 100644
--- a/src/platform/Darwin/BleConnectionDelegate.h
+++ b/src/platform/Darwin/BleConnectionDelegateImpl.h
@@ -1,6 +1,6 @@
 /*
  *
- *    Copyright (c) 2020 Project CHIP Authors
+ *    Copyright (c) 2020-2025 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.
diff --git a/src/platform/Darwin/BleConnectionDelegateImpl.mm b/src/platform/Darwin/BleConnectionDelegateImpl.mm
index 7b5d68bab13523..d29525ea60d951 100644
--- a/src/platform/Darwin/BleConnectionDelegateImpl.mm
+++ b/src/platform/Darwin/BleConnectionDelegateImpl.mm
@@ -1,6 +1,6 @@
 /*
  *
- *    Copyright (c) 2020-2021 Project CHIP Authors
+ *    Copyright (c) 2020-2025 Project CHIP Authors
  *    Copyright (c) 2015-2017 Nest Labs, Inc.
  *
  *    Licensed under the Apache License, Version 2.0 (the "License");
@@ -28,24 +28,26 @@
 #include <ble/Ble.h>
 #include <lib/support/logging/CHIPLogging.h>
 #include <platform/CHIPDeviceLayer.h>
-#include <platform/Darwin/BleConnectionDelegate.h>
+#include <platform/Darwin/BleConnectionDelegateImpl.h>
 #include <platform/Darwin/BleScannerDelegate.h>
+#include <platform/Darwin/BleUtils.h>
 #include <platform/LockTracker.h>
 #include <setup_payload/SetupPayload.h>
 #include <tracing/metric_event.h>
 
-#import "MTRUUIDHelper.h"
+#import <CoreBluetooth/CoreBluetooth.h>
+
 #import "PlatformMetricKeys.h"
 
 using namespace chip::Ble;
 using namespace chip::DeviceLayer;
+using namespace chip::DeviceLayer::Internal;
 using namespace chip::Tracing::DarwinPlatform;
 
 constexpr uint64_t kScanningWithDiscriminatorTimeoutInSeconds = 60;
 constexpr uint64_t kPreWarmScanTimeoutInSeconds = 120;
 constexpr uint64_t kCachePeripheralTimeoutInSeconds
     = static_cast<uint64_t>(CHIP_DEVICE_CONFIG_BLE_SLOW_ADVERTISING_INTERVAL_MAX / 1000.0 * 8.0 * 0.625);
-constexpr char kBleWorkQueueName[] = "org.csa-iot.matter.framework.ble.workqueue";
 
 typedef NS_ENUM(uint8_t, BleConnectionMode) {
     kUndefined = 0,
@@ -56,25 +58,23 @@ typedef NS_ENUM(uint8_t, BleConnectionMode) {
 
 @interface BleConnection : NSObject <CBCentralManagerDelegate, CBPeripheralDelegate>
 
-@property (strong, nonatomic) dispatch_queue_t chipWorkQueue;
-@property (strong, nonatomic) dispatch_queue_t workQueue;
+@property (strong, nonatomic) dispatch_queue_t workQueue; // the CHIP work queue
 @property (strong, nonatomic) CBCentralManager * centralManager;
 @property (strong, nonatomic) CBPeripheral * peripheral;
-@property (strong, nonatomic) CBUUID * shortServiceUUID;
 @property (nonatomic, readonly, nullable) dispatch_source_t timer;
 @property (nonatomic, readonly) BleConnectionMode currentMode;
 @property (strong, nonatomic) NSMutableDictionary<CBPeripheral *, NSDictionary *> * cachedPeripherals;
-@property (unsafe_unretained, nonatomic) bool found;
-@property (unsafe_unretained, nonatomic) chip::SetupDiscriminator deviceDiscriminator;
-@property (unsafe_unretained, nonatomic) void * appState;
-@property (unsafe_unretained, nonatomic) BleConnectionDelegate::OnConnectionCompleteFunct onConnectionComplete;
-@property (unsafe_unretained, nonatomic) BleConnectionDelegate::OnConnectionErrorFunct onConnectionError;
-@property (unsafe_unretained, nonatomic) chip::DeviceLayer::BleScannerDelegate * scannerDelegate;
-@property (unsafe_unretained, nonatomic) chip::Ble::BleLayer * mBleLayer;
-
-- (id)initWithQueue:(dispatch_queue_t)queue;
-- (id)initWithDelegate:(chip::DeviceLayer::BleScannerDelegate *)delegate prewarm:(bool)prewarm queue:(dispatch_queue_t)queue;
-- (id)initWithDiscriminator:(const chip::SetupDiscriminator &)deviceDiscriminator queue:(dispatch_queue_t)queue;
+@property (assign, nonatomic) bool found;
+@property (assign, nonatomic) chip::SetupDiscriminator deviceDiscriminator;
+@property (assign, nonatomic) void * appState;
+@property (assign, nonatomic) BleConnectionDelegate::OnConnectionCompleteFunct onConnectionComplete;
+@property (assign, nonatomic) BleConnectionDelegate::OnConnectionErrorFunct onConnectionError;
+@property (assign, nonatomic) chip::DeviceLayer::BleScannerDelegate * scannerDelegate;
+@property (assign, nonatomic) chip::Ble::BleLayer * mBleLayer;
+
+- (instancetype)initWithDelegate:(chip::DeviceLayer::BleScannerDelegate *)delegate prewarm:(bool)prewarm;
+- (instancetype)initWithDiscriminator:(const chip::SetupDiscriminator &)deviceDiscriminator;
+
 - (void)setBleLayer:(chip::Ble::BleLayer *)bleLayer;
 - (void)start;
 - (void)stop;
@@ -92,7 +92,6 @@ - (void)removePeripheralsFromCache;
 namespace DeviceLayer {
     namespace Internal {
         BleConnection * ble;
-        dispatch_queue_t bleWorkQueue;
 
         void BleConnectionDelegateImpl::NewConnection(
             Ble::BleLayer * bleLayer, void * appState, const SetupDiscriminator & inDeviceDiscriminator)
@@ -103,30 +102,24 @@ - (void)removePeripheralsFromCache;
             SetupDiscriminator deviceDiscriminator = inDeviceDiscriminator;
 
             ChipLogProgress(Ble, "ConnectionDelegate NewConnection with discriminator");
-            if (!bleWorkQueue) {
-                bleWorkQueue = dispatch_queue_create(kBleWorkQueueName, DISPATCH_QUEUE_SERIAL_WITH_AUTORELEASE_POOL);
-            }
-
-            dispatch_async(bleWorkQueue, ^{
-                // If the previous connection delegate was not a try to connect to something, just reuse it instead of
-                // creating a brand new connection but update the discriminator and the ble layer members.
-                if (ble and ![ble isConnecting]) {
-                    [ble setBleLayer:bleLayer];
-                    ble.appState = appState;
-                    ble.onConnectionComplete = OnConnectionComplete;
-                    ble.onConnectionError = OnConnectionError;
-                    [ble updateWithDiscriminator:deviceDiscriminator];
-                    return;
-                }
-
-                [ble stop];
-                ble = [[BleConnection alloc] initWithDiscriminator:deviceDiscriminator queue:bleWorkQueue];
+            // If the previous connection delegate was not a try to connect to something, just reuse it instead of
+            // creating a brand new connection but update the discriminator and the ble layer members.
+            if (ble and ![ble isConnecting]) {
                 [ble setBleLayer:bleLayer];
                 ble.appState = appState;
                 ble.onConnectionComplete = OnConnectionComplete;
                 ble.onConnectionError = OnConnectionError;
-                ble.centralManager = [ble.centralManager initWithDelegate:ble queue:bleWorkQueue];
-            });
+                [ble updateWithDiscriminator:deviceDiscriminator];
+                return;
+            }
+
+            [ble stop];
+            ble = [[BleConnection alloc] initWithDiscriminator:deviceDiscriminator];
+            [ble setBleLayer:bleLayer];
+            ble.appState = appState;
+            ble.onConnectionComplete = OnConnectionComplete;
+            ble.onConnectionError = OnConnectionError;
+            ble.centralManager = [ble.centralManager initWithDelegate:ble queue:ble.workQueue];
         }
 
         void BleConnectionDelegateImpl::NewConnection(Ble::BleLayer * bleLayer, void * appState, BLE_CONNECTION_OBJECT connObj)
@@ -134,31 +127,25 @@ - (void)removePeripheralsFromCache;
             assertChipStackLockedByCurrentThread();
 
             ChipLogProgress(Ble, "ConnectionDelegate NewConnection with conn obj: %p", connObj);
-
-            if (!bleWorkQueue) {
-                bleWorkQueue = dispatch_queue_create(kBleWorkQueueName, DISPATCH_QUEUE_SERIAL_WITH_AUTORELEASE_POOL);
-            }
-
-            CBPeripheral * peripheral = (__bridge CBPeripheral *) connObj; // bridge (and retain) before dispatching
-            dispatch_async(bleWorkQueue, ^{
-                // The BLE_CONNECTION_OBJECT represent a CBPeripheral object. In order for it to be valid the central
-                // manager needs to still be running.
-                if (!ble || [ble isConnecting]) {
-                    if (OnConnectionError) {
-                        auto workQueue = chip::DeviceLayer::PlatformMgrImpl().GetWorkQueue();
-                        dispatch_async(workQueue, ^{
-                            OnConnectionError(appState, CHIP_ERROR_INCORRECT_STATE);
-                        });
-                    }
-                    return;
+            CBPeripheral * peripheral = CBPeripheralFromBleConnObject(connObj);
+
+            // The BLE_CONNECTION_OBJECT represents a CBPeripheral object. In order for it to be valid the central
+            // manager needs to still be running.
+            if (!ble || [ble isConnecting]) {
+                if (OnConnectionError) {
+                    // Avoid calling back prior to returning
+                    dispatch_async(PlatformMgrImpl().GetWorkQueue(), ^{
+                        OnConnectionError(appState, CHIP_ERROR_INCORRECT_STATE);
+                    });
                 }
+                return;
+            }
 
-                [ble setBleLayer:bleLayer];
-                ble.appState = appState;
-                ble.onConnectionComplete = OnConnectionComplete;
-                ble.onConnectionError = OnConnectionError;
-                [ble updateWithPeripheral:peripheral];
-            });
+            [ble setBleLayer:bleLayer];
+            ble.appState = appState;
+            ble.onConnectionComplete = OnConnectionComplete;
+            ble.onConnectionError = OnConnectionError;
+            [ble updateWithPeripheral:peripheral];
         }
 
         void BleConnectionDelegateImpl::StartScan(BleScannerDelegate * delegate, BleScanMode mode)
@@ -168,39 +155,33 @@ - (void)removePeripheralsFromCache;
             bool prewarm = (mode == BleScanMode::kPreWarm);
             ChipLogProgress(Ble, "ConnectionDelegate StartScan (%s)", (prewarm ? "pre-warm" : "default"));
 
-            if (!bleWorkQueue) {
-                bleWorkQueue = dispatch_queue_create(kBleWorkQueueName, DISPATCH_QUEUE_SERIAL_WITH_AUTORELEASE_POOL);
-            }
-
-            dispatch_async(bleWorkQueue, ^{
-                // Pre-warming is best-effort, don't cancel an ongoing scan or connection attempt
-                if (prewarm && ble) {
-                    // TODO: Once we get rid of the separate BLE queue we can just return CHIP_ERROR_BUSY.
-                    // That will also allow these cases to be distinguished in our metric.
-                    ChipLogProgress(Ble, "Not starting pre-warm scan, an operation is already in progress");
-                    if (delegate) {
-                        dispatch_async(PlatformMgrImpl().GetWorkQueue(), ^{
-                            delegate->OnBleScanStopped();
-                        });
-                    }
-                    return;
+            // Pre-warming is best-effort, don't cancel an ongoing scan or connection attempt
+            if (prewarm && ble) {
+                // TODO: Once we get rid of the separate BLE queue we can just return CHIP_ERROR_BUSY.
+                // That will also allow these cases to be distinguished in our metric.
+                ChipLogProgress(Ble, "Not starting pre-warm scan, an operation is already in progress");
+                if (delegate) {
+                    dispatch_async(PlatformMgrImpl().GetWorkQueue(), ^{
+                        delegate->OnBleScanStopped();
+                    });
                 }
+                return;
+            }
 
-                // If the previous connection delegate was not a try to connect to something, just reuse it instead of
-                // creating a brand new connection but update the discriminator and the ble layer members.
-                if (ble and ![ble isConnecting]) {
-                    [ble updateWithDelegate:delegate prewarm:prewarm];
-                    return;
-                }
+            // If the previous connection delegate was not a try to connect to something, just reuse it instead of
+            // creating a brand new connection but update the discriminator and the ble layer members.
+            if (ble and ![ble isConnecting]) {
+                [ble updateWithDelegate:delegate prewarm:prewarm];
+                return;
+            }
 
-                [ble stop];
-                ble = [[BleConnection alloc] initWithDelegate:delegate prewarm:prewarm queue:bleWorkQueue];
-                // Do _not_ set onConnectionComplete and onConnectionError
-                // here.  The connection callbacks we have expect an appState
-                // that we do not have here, and in any case connection
-                // complete/error make no sense for a scan.
-                ble.centralManager = [ble.centralManager initWithDelegate:ble queue:bleWorkQueue];
-            });
+            [ble stop];
+            ble = [[BleConnection alloc] initWithDelegate:delegate prewarm:prewarm];
+            // Do _not_ set onConnectionComplete and onConnectionError
+            // here.  The connection callbacks we have expect an appState
+            // that we do not have here, and in any case connection
+            // complete/error make no sense for a scan.
+            ble.centralManager = [ble.centralManager initWithDelegate:ble queue:ble.workQueue];
         }
 
         void BleConnectionDelegateImpl::StopScan()
@@ -218,16 +199,8 @@ - (void)removePeripheralsFromCache;
         CHIP_ERROR BleConnectionDelegateImpl::DoCancel()
         {
             assertChipStackLockedByCurrentThread();
-            if (bleWorkQueue == nil) {
-                return CHIP_NO_ERROR;
-            }
-
-            dispatch_async(bleWorkQueue, ^{
-                [ble stop];
-                ble = nil;
-            });
-
-            bleWorkQueue = nil;
+            [ble stop];
+            ble = nil;
             return CHIP_NO_ERROR;
         }
     } // namespace Internal
@@ -239,15 +212,16 @@ @interface BleConnection ()
 @property (nonatomic, readonly) int32_t totalDevicesRemoved;
 @end
 
-@implementation BleConnection
+@implementation BleConnection {
+    CBUUID * _chipServiceUUID;
+}
 
-- (id)initWithQueue:(dispatch_queue_t)queue
+- (instancetype)init
 {
     self = [super init];
     if (self) {
-        self.shortServiceUUID = [MTRUUIDHelper GetShortestServiceUUID:&chip::Ble::CHIP_BLE_SVC_ID];
-        _chipWorkQueue = chip::DeviceLayer::PlatformMgrImpl().GetWorkQueue();
-        _workQueue = queue;
+        _chipServiceUUID = CBUUIDFromBleUUID(chip::Ble::CHIP_BLE_SVC_ID);
+        _workQueue = chip::DeviceLayer::PlatformMgrImpl().GetWorkQueue();
         _centralManager = [CBCentralManager alloc];
         _found = false;
         _cachedPeripherals = [[NSMutableDictionary alloc] init];
@@ -258,9 +232,9 @@ - (id)initWithQueue:(dispatch_queue_t)queue
     return self;
 }
 
-- (id)initWithDelegate:(chip::DeviceLayer::BleScannerDelegate *)delegate prewarm:(bool)prewarm queue:(dispatch_queue_t)queue
+- (instancetype)initWithDelegate:(chip::DeviceLayer::BleScannerDelegate *)delegate prewarm:(bool)prewarm
 {
-    self = [self initWithQueue:queue];
+    self = [self init];
     if (self) {
         _scannerDelegate = delegate;
         if (prewarm) {
@@ -274,9 +248,9 @@ - (id)initWithDelegate:(chip::DeviceLayer::BleScannerDelegate *)delegate prewarm
     return self;
 }
 
-- (id)initWithDiscriminator:(const chip::SetupDiscriminator &)deviceDiscriminator queue:(dispatch_queue_t)queue
+- (id)initWithDiscriminator:(const chip::SetupDiscriminator &)deviceDiscriminator
 {
-    self = [self initWithQueue:queue];
+    self = [self init];
     if (self) {
         _deviceDiscriminator = deviceDiscriminator;
         _currentMode = kConnecting;
@@ -318,26 +292,23 @@ - (void)clearTimer
 // All our callback dispatch must happen on _chipWorkQueue
 - (void)dispatchConnectionError:(CHIP_ERROR)error
 {
-    dispatch_async(_chipWorkQueue, ^{
-        if (self.onConnectionError != nil) {
-            self.onConnectionError(self.appState, error);
-        }
-    });
+    if (self.onConnectionError != nil) {
+        self.onConnectionError(self.appState, error);
+    }
 }
 
 - (void)dispatchConnectionComplete:(CBPeripheral *)peripheral
 {
-    dispatch_async(_chipWorkQueue, ^{
-        if (self.onConnectionComplete != nil) {
-            self.onConnectionComplete(self.appState, (__bridge void *) peripheral);
-        }
-    });
+    if (self.onConnectionComplete != nil) {
+        self.onConnectionComplete(self.appState, BleConnObjectFromCBPeripheral(peripheral));
+    }
 }
 
 // Start CBCentralManagerDelegate
 
 - (void)centralManagerDidUpdateState:(CBCentralManager *)central
 {
+    assertChipStackLockedByCurrentThread();
     MATTER_LOG_METRIC(kMetricBLECentralManagerState, static_cast<uint32_t>(central.state));
 
     switch (central.state) {
@@ -370,15 +341,9 @@ - (void)centralManager:(CBCentralManager *)central
         advertisementData:(NSDictionary *)advertisementData
                      RSSI:(NSNumber *)RSSI
 {
-    NSDictionary * servicesData = [advertisementData objectForKey:CBAdvertisementDataServiceDataKey];
-    NSData * serviceData;
-    for (CBUUID * serviceUUID in servicesData) {
-        if ([serviceUUID.data isEqualToData:_shortServiceUUID.data]) {
-            serviceData = [servicesData objectForKey:serviceUUID];
-            break;
-        }
-    }
+    assertChipStackLockedByCurrentThread();
 
+    NSData * serviceData = advertisementData[CBAdvertisementDataServiceDataKey][_chipServiceUUID];
     if (!serviceData) {
         return;
     }
@@ -443,8 +408,10 @@ - (BOOL)checkDiscriminator:(uint16_t)discriminator
 
 - (void)centralManager:(CBCentralManager *)central didConnectPeripheral:(CBPeripheral *)peripheral
 {
+    assertChipStackLockedByCurrentThread();
     MATTER_LOG_METRIC_END(kMetricBLEConnectPeripheral);
     MATTER_LOG_METRIC_BEGIN(kMetricBLEDiscoveredServices);
+
     [peripheral setDelegate:self];
     [peripheral discoverServices:nil];
     [self stopScanning];
@@ -456,6 +423,8 @@ - (void)centralManager:(CBCentralManager *)central didConnectPeripheral:(CBPerip
 
 - (void)peripheral:(CBPeripheral *)peripheral didDiscoverServices:(NSError *)error
 {
+    assertChipStackLockedByCurrentThread();
+
     if (nil != error) {
         ChipLogError(Ble, "BLE:Error finding Chip Service in the device: [%s]", [error.localizedDescription UTF8String]);
     }
@@ -463,7 +432,7 @@ - (void)peripheral:(CBPeripheral *)peripheral didDiscoverServices:(NSError *)err
     MATTER_LOG_METRIC_END(kMetricBLEDiscoveredServices, CHIP_ERROR(chip::ChipError::Range::kOS, static_cast<uint32_t>(error.code)));
 
     for (CBService * service in peripheral.services) {
-        if ([service.UUID.data isEqualToData:_shortServiceUUID.data] && !self.found) {
+        if ([service.UUID isEqual:_chipServiceUUID] && !self.found) {
             MATTER_LOG_METRIC_BEGIN(kMetricBLEDiscoveredCharacteristics);
             [peripheral discoverCharacteristics:nil forService:service];
             self.found = true;
@@ -480,6 +449,7 @@ - (void)peripheral:(CBPeripheral *)peripheral didDiscoverServices:(NSError *)err
 
 - (void)peripheral:(CBPeripheral *)peripheral didDiscoverCharacteristicsForService:(CBService *)service error:(NSError *)error
 {
+    assertChipStackLockedByCurrentThread();
     MATTER_LOG_METRIC_END(kMetricBLEDiscoveredCharacteristics, CHIP_ERROR(chip::ChipError::Range::kOS, static_cast<uint32_t>(error.code)));
 
     if (nil != error) {
@@ -495,20 +465,17 @@ - (void)peripheral:(CBPeripheral *)peripheral
     didWriteValueForCharacteristic:(CBCharacteristic *)characteristic
                              error:(NSError *)error
 {
+    assertChipStackLockedByCurrentThread();
+
     if (nil == error) {
-        chip::Ble::ChipBleUUID svcId;
-        chip::Ble::ChipBleUUID charId;
-        [BleConnection fillServiceWithCharacteristicUuids:characteristic svcId:&svcId charId:&charId];
-        dispatch_async(_chipWorkQueue, ^{
-            _mBleLayer->HandleWriteConfirmation((__bridge void *) peripheral, &svcId, &charId);
-        });
+        ChipBleUUID svcId = BleUUIDFromCBUUD(characteristic.service.UUID);
+        ChipBleUUID charId = BleUUIDFromCBUUD(characteristic.UUID);
+        _mBleLayer->HandleWriteConfirmation(BleConnObjectFromCBPeripheral(peripheral), &svcId, &charId);
     } else {
         ChipLogError(
             Ble, "BLE:Error writing Characteristics in Chip service on the device: [%s]", [error.localizedDescription UTF8String]);
-        dispatch_async(_chipWorkQueue, ^{
-            MATTER_LOG_METRIC(kMetricBLEWriteChrValueFailed, BLE_ERROR_GATT_WRITE_FAILED);
-            _mBleLayer->HandleConnectionError((__bridge void *) peripheral, BLE_ERROR_GATT_WRITE_FAILED);
-        });
+        MATTER_LOG_METRIC(kMetricBLEWriteChrValueFailed, BLE_ERROR_GATT_WRITE_FAILED);
+        _mBleLayer->HandleConnectionError(BleConnObjectFromCBPeripheral(peripheral), BLE_ERROR_GATT_WRITE_FAILED);
     }
 }
 
@@ -516,34 +483,31 @@ - (void)peripheral:(CBPeripheral *)peripheral
     didUpdateNotificationStateForCharacteristic:(CBCharacteristic *)characteristic
                                           error:(NSError *)error
 {
+    assertChipStackLockedByCurrentThread();
+
     bool isNotifying = characteristic.isNotifying;
 
     if (nil == error) {
-        chip::Ble::ChipBleUUID svcId;
-        chip::Ble::ChipBleUUID charId;
-        [BleConnection fillServiceWithCharacteristicUuids:characteristic svcId:&svcId charId:&charId];
-
-        dispatch_async(_chipWorkQueue, ^{
-            if (isNotifying) {
-                _mBleLayer->HandleSubscribeComplete((__bridge void *) peripheral, &svcId, &charId);
-            } else {
-                _mBleLayer->HandleUnsubscribeComplete((__bridge void *) peripheral, &svcId, &charId);
-            }
-        });
+        ChipBleUUID svcId = BleUUIDFromCBUUD(characteristic.service.UUID);
+        ChipBleUUID charId = BleUUIDFromCBUUD(characteristic.UUID);
+        if (isNotifying) {
+            _mBleLayer->HandleSubscribeComplete(BleConnObjectFromCBPeripheral(peripheral), &svcId, &charId);
+        } else {
+            _mBleLayer->HandleUnsubscribeComplete(BleConnObjectFromCBPeripheral(peripheral), &svcId, &charId);
+        }
     } else {
         ChipLogError(Ble, "BLE:Error subscribing/unsubcribing some characteristic on the device: [%s]",
             [error.localizedDescription UTF8String]);
-        dispatch_async(_chipWorkQueue, ^{
-            if (isNotifying) {
-                MATTER_LOG_METRIC(kMetricBLEUpdateNotificationStateForChrFailed, BLE_ERROR_GATT_WRITE_FAILED);
-                // we're still notifying, so we must failed the unsubscription
-                _mBleLayer->HandleConnectionError((__bridge void *) peripheral, BLE_ERROR_GATT_UNSUBSCRIBE_FAILED);
-            } else {
-                // we're not notifying, so we must failed the subscription
-                MATTER_LOG_METRIC(kMetricBLEUpdateNotificationStateForChrFailed, BLE_ERROR_GATT_SUBSCRIBE_FAILED);
-                _mBleLayer->HandleConnectionError((__bridge void *) peripheral, BLE_ERROR_GATT_SUBSCRIBE_FAILED);
-            }
-        });
+
+        if (isNotifying) {
+            MATTER_LOG_METRIC(kMetricBLEUpdateNotificationStateForChrFailed, BLE_ERROR_GATT_WRITE_FAILED);
+            // we're still notifying, so we must failed the unsubscription
+            _mBleLayer->HandleConnectionError(BleConnObjectFromCBPeripheral(peripheral), BLE_ERROR_GATT_UNSUBSCRIBE_FAILED);
+        } else {
+            // we're not notifying, so we must failed the subscription
+            MATTER_LOG_METRIC(kMetricBLEUpdateNotificationStateForChrFailed, BLE_ERROR_GATT_SUBSCRIBE_FAILED);
+            _mBleLayer->HandleConnectionError(BleConnObjectFromCBPeripheral(peripheral), BLE_ERROR_GATT_SUBSCRIBE_FAILED);
+        }
     }
 }
 
@@ -551,34 +515,31 @@ - (void)peripheral:(CBPeripheral *)peripheral
     didUpdateValueForCharacteristic:(CBCharacteristic *)characteristic
                               error:(NSError *)error
 {
+    assertChipStackLockedByCurrentThread();
+
     if (nil == error) {
-        chip::Ble::ChipBleUUID svcId;
-        chip::Ble::ChipBleUUID charId;
-        [BleConnection fillServiceWithCharacteristicUuids:characteristic svcId:&svcId charId:&charId];
+        ChipBleUUID svcId = BleUUIDFromCBUUD(characteristic.service.UUID);
+        ChipBleUUID charId = BleUUIDFromCBUUD(characteristic.UUID);
         auto * value = characteristic.value; // read immediately before dispatching
 
-        dispatch_async(_chipWorkQueue, ^{
-            // build a inet buffer from the rxEv and send to blelayer.
-            auto msgBuf = chip::System::PacketBufferHandle::NewWithData(value.bytes, value.length);
-
-            if (msgBuf.IsNull()) {
-                ChipLogError(Ble, "Failed at allocating buffer for incoming BLE data");
-                MATTER_LOG_METRIC(kMetricBLEUpdateValueForChrFailed, CHIP_ERROR_NO_MEMORY);
-                _mBleLayer->HandleConnectionError((__bridge void *) peripheral, CHIP_ERROR_NO_MEMORY);
-            } else if (!_mBleLayer->HandleIndicationReceived((__bridge void *) peripheral, &svcId, &charId, std::move(msgBuf))) {
-                // since this error comes from device manager core
-                // we assume it would do the right thing, like closing the connection
-                ChipLogError(Ble, "Failed at handling incoming BLE data");
-                MATTER_LOG_METRIC(kMetricBLEUpdateValueForChrFailed, CHIP_ERROR_INCORRECT_STATE);
-            }
-        });
+        // build a inet buffer from the rxEv and send to blelayer.
+        auto msgBuf = chip::System::PacketBufferHandle::NewWithData(value.bytes, value.length);
+
+        if (msgBuf.IsNull()) {
+            ChipLogError(Ble, "Failed at allocating buffer for incoming BLE data");
+            MATTER_LOG_METRIC(kMetricBLEUpdateValueForChrFailed, CHIP_ERROR_NO_MEMORY);
+            _mBleLayer->HandleConnectionError(BleConnObjectFromCBPeripheral(peripheral), CHIP_ERROR_NO_MEMORY);
+        } else if (!_mBleLayer->HandleIndicationReceived(BleConnObjectFromCBPeripheral(peripheral), &svcId, &charId, std::move(msgBuf))) {
+            // since this error comes from device manager core
+            // we assume it would do the right thing, like closing the connection
+            ChipLogError(Ble, "Failed at handling incoming BLE data");
+            MATTER_LOG_METRIC(kMetricBLEUpdateValueForChrFailed, CHIP_ERROR_INCORRECT_STATE);
+        }
     } else {
         ChipLogError(
             Ble, "BLE:Error receiving indication of Characteristics on the device: [%s]", [error.localizedDescription UTF8String]);
-        dispatch_async(_chipWorkQueue, ^{
-            MATTER_LOG_METRIC(kMetricBLEUpdateValueForChrFailed, BLE_ERROR_GATT_INDICATE_FAILED);
-            _mBleLayer->HandleConnectionError((__bridge void *) peripheral, BLE_ERROR_GATT_INDICATE_FAILED);
-        });
+        MATTER_LOG_METRIC(kMetricBLEUpdateValueForChrFailed, BLE_ERROR_GATT_INDICATE_FAILED);
+        _mBleLayer->HandleConnectionError(BleConnObjectFromCBPeripheral(peripheral), BLE_ERROR_GATT_INDICATE_FAILED);
     }
 }
 
@@ -603,30 +564,20 @@ - (void)stop
     [self stopScanning];
     [self removePeripheralsFromCache];
 
-    if (!_centralManager && !_peripheral) {
-        return;
+    if (_peripheral) {
+        // Close all BLE connections before we release CB objects
+        _mBleLayer->CloseAllBleConnections();
+        _peripheral = nil;
     }
 
-    // Properly closing the underlying ble connections needs to happens
-    // on the chip work queue. At the same time the SDK is trying to
-    // properly unsubscribe and shutdown the connection, so if we nullify
-    // the centralManager and the peripheral members too early it won't be
-    // able to reach those.
-    // This is why closing connections happens as 2 async steps.
-    dispatch_async(_chipWorkQueue, ^{
-        if (_peripheral) {
-            _mBleLayer->CloseAllBleConnections();
-        }
+    if (_centralManager) {
+        _centralManager.delegate = nil;
+        _centralManager = nil;
+    }
 
-        dispatch_async(_workQueue, ^{
-            _centralManager.delegate = nil;
-            _centralManager = nil;
-            _peripheral = nil;
-            if (chip::DeviceLayer::Internal::ble == self) {
-                chip::DeviceLayer::Internal::ble = nil;
-            }
-        });
-    });
+    if (chip::DeviceLayer::Internal::ble == self) {
+        chip::DeviceLayer::Internal::ble = nil;
+    }
 }
 
 - (void)_resetCounters
@@ -647,7 +598,7 @@ - (void)startScanning
     [self _resetCounters];
 
     auto scanOptions = @{ CBCentralManagerScanOptionAllowDuplicatesKey : @YES };
-    [_centralManager scanForPeripheralsWithServices:@[ _shortServiceUUID ] options:scanOptions];
+    [_centralManager scanForPeripheralsWithServices:@[ _chipServiceUUID ] options:scanOptions];
 }
 
 - (void)stopScanning
@@ -680,9 +631,7 @@ - (void)detachScannerDelegate
     auto * existingDelegate = _scannerDelegate;
     if (existingDelegate) {
         _scannerDelegate = nullptr;
-        dispatch_async(_chipWorkQueue, ^{
-            existingDelegate->OnBleScanStopped();
-        });
+        existingDelegate->OnBleScanStopped();
     }
 }
 
@@ -693,11 +642,9 @@ - (void)updateWithDelegate:(chip::DeviceLayer::BleScannerDelegate *)delegate pre
     if (delegate) {
         for (CBPeripheral * cachedPeripheral in _cachedPeripherals) {
             NSData * serviceData = _cachedPeripherals[cachedPeripheral][@"data"];
-            dispatch_async(_chipWorkQueue, ^{
-                ChipBLEDeviceIdentificationInfo info;
-                memcpy(&info, [serviceData bytes], sizeof(info));
-                delegate->OnBleScanAdd((__bridge void *) cachedPeripheral, info);
-            });
+            ChipBLEDeviceIdentificationInfo info;
+            memcpy(&info, [serviceData bytes], sizeof(info));
+            delegate->OnBleScanAdd(BleConnObjectFromCBPeripheral(cachedPeripheral), info);
         }
         _scannerDelegate = delegate;
     }
@@ -768,12 +715,10 @@ - (void)addPeripheralToCache:(CBPeripheral *)peripheral data:(NSData *)data
         ChipLogProgress(Ble, "Adding peripheral %p to the cache", peripheral);
         auto delegate = _scannerDelegate;
         if (delegate) {
-            dispatch_async(_chipWorkQueue, ^{
-                ChipBLEDeviceIdentificationInfo info;
-                auto bytes = (const uint8_t *) [data bytes];
-                memcpy(&info, bytes, sizeof(info));
-                delegate->OnBleScanAdd((__bridge void *) peripheral, info);
-            });
+            ChipBLEDeviceIdentificationInfo info;
+            auto bytes = (const uint8_t *) [data bytes];
+            memcpy(&info, bytes, sizeof(info));
+            delegate->OnBleScanAdd(BleConnObjectFromCBPeripheral(peripheral), info);
         }
 
         timeoutTimer = dispatch_source_create(DISPATCH_SOURCE_TYPE_TIMER, 0, 0, _workQueue);
@@ -820,9 +765,7 @@ - (void)removePeripheralFromCache:(CBPeripheral *)peripheral
 
         auto delegate = _scannerDelegate;
         if (delegate) {
-            dispatch_async(_chipWorkQueue, ^{
-                delegate->OnBleScanRemove((__bridge void *) peripheral);
-            });
+            delegate->OnBleScanRemove(BleConnObjectFromCBPeripheral(peripheral));
         }
     }
 }
@@ -834,55 +777,6 @@ - (void)removePeripheralsFromCache
     }
 }
 
-/**
- * private static method to copy service and characteristic UUIDs from CBCharacteristic to a pair of ChipBleUUID objects.
- * this is used in calls into Chip layer to decouple it from CoreBluetooth
- *
- * @param[in] characteristic the source characteristic
- * @param[in] svcId the destination service UUID
- * @param[in] charId the destination characteristic UUID
- *
- */
-+ (void)fillServiceWithCharacteristicUuids:(CBCharacteristic *)characteristic
-                                     svcId:(chip::Ble::ChipBleUUID *)svcId
-                                    charId:(chip::Ble::ChipBleUUID *)charId
-{
-    static const size_t FullUUIDLength = 16;
-    if ((FullUUIDLength != sizeof(charId->bytes)) || (FullUUIDLength != sizeof(svcId->bytes))
-        || (FullUUIDLength != characteristic.UUID.data.length)) {
-        // we're dead. we expect the data length to be the same (16-byte) across the board
-        ChipLogError(Ble, "UUID of characteristic is incompatible");
-        return;
-    }
-
-    memcpy(charId->bytes, characteristic.UUID.data.bytes, sizeof(charId->bytes));
-    memset(svcId->bytes, 0, sizeof(svcId->bytes));
-
-    // Expand service UUID back to 16-byte long as that's what the BLE Layer expects
-    // this is a buffer pre-filled with BLE service UUID Base
-    // byte 0 to 3 are reserved for shorter versions of BLE service UUIDs
-    // For 4-byte service UUIDs, all bytes from 0 to 3 are used
-    // For 2-byte service UUIDs, byte 0 and 1 shall be 0
-    uint8_t serviceFullUUID[FullUUIDLength]
-        = { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10, 0x00, 0x80, 0x00, 0x00, 0x80, 0x5F, 0x9B, 0x34, 0xFB };
-
-    switch (characteristic.service.UUID.data.length) {
-    case 2:
-        // copy the 2-byte service UUID onto the right offset
-        memcpy(serviceFullUUID + 2, characteristic.service.UUID.data.bytes, 2);
-        break;
-    case 4:
-        // flow through
-    case 16:
-        memcpy(serviceFullUUID, characteristic.service.UUID.data.bytes, characteristic.service.UUID.data.length);
-        break;
-    default:
-        // we're dead. we expect the data length to be the same (16-byte) across the board
-        ChipLogError(Ble, "Service UUIDs are incompatible");
-    }
-    memcpy(svcId->bytes, serviceFullUUID, sizeof(svcId->bytes));
-}
-
 - (void)setBleLayer:(chip::Ble::BleLayer *)bleLayer
 {
     _mBleLayer = bleLayer;
diff --git a/src/platform/Darwin/BlePlatformConfig.h b/src/platform/Darwin/BlePlatformConfig.h
index a27bfa28784de8..096af0478dd99a 100644
--- a/src/platform/Darwin/BlePlatformConfig.h
+++ b/src/platform/Darwin/BlePlatformConfig.h
@@ -26,6 +26,10 @@
 
 // ==================== Platform Adaptations ====================
 
+#define BLE_CONNECTION_OBJECT void * // actually __unsafe_unretained CBPeripheral *
+#define BLE_CONNECTION_UNINITIALIZED nullptr
+#define BLE_USES_DEVICE_EVENTS 0
+
 // ========== Platform-specific Configuration Overrides =========
 
 /* none so far */
diff --git a/src/platform/Darwin/BlePlatformDelegate.h b/src/platform/Darwin/BlePlatformDelegateImpl.h
similarity index 71%
rename from src/platform/Darwin/BlePlatformDelegate.h
rename to src/platform/Darwin/BlePlatformDelegateImpl.h
index d53f9007ca1b59..256f36fe733a86 100644
--- a/src/platform/Darwin/BlePlatformDelegate.h
+++ b/src/platform/Darwin/BlePlatformDelegateImpl.h
@@ -20,9 +20,6 @@
 #include <ble/Ble.h>
 #include <system/SystemPacketBuffer.h>
 
-using ::chip::Ble::ChipBleUUID;
-using ::chip::System::PacketBufferHandle;
-
 namespace chip {
 namespace DeviceLayer {
 namespace Internal {
@@ -30,16 +27,16 @@ namespace Internal {
 class BlePlatformDelegateImpl : public Ble::BlePlatformDelegate
 {
 public:
-    CHIP_ERROR SubscribeCharacteristic(BLE_CONNECTION_OBJECT connObj, const ChipBleUUID * svcId,
-                                       const ChipBleUUID * charId) override;
-    CHIP_ERROR UnsubscribeCharacteristic(BLE_CONNECTION_OBJECT connObj, const ChipBleUUID * svcId,
-                                         const ChipBleUUID * charId) override;
+    CHIP_ERROR SubscribeCharacteristic(BLE_CONNECTION_OBJECT connObj, const Ble::ChipBleUUID * svcId,
+                                       const Ble::ChipBleUUID * charId) override;
+    CHIP_ERROR UnsubscribeCharacteristic(BLE_CONNECTION_OBJECT connObj, const Ble::ChipBleUUID * svcId,
+                                         const Ble::ChipBleUUID * charId) override;
     CHIP_ERROR CloseConnection(BLE_CONNECTION_OBJECT connObj) override;
     uint16_t GetMTU(BLE_CONNECTION_OBJECT connObj) const override;
-    CHIP_ERROR SendIndication(BLE_CONNECTION_OBJECT connObj, const ChipBleUUID * svcId, const ChipBleUUID * charId,
-                              PacketBufferHandle pBuf) override;
-    CHIP_ERROR SendWriteRequest(BLE_CONNECTION_OBJECT connObj, const ChipBleUUID * svcId, const ChipBleUUID * charId,
-                                PacketBufferHandle pBuf) override;
+    CHIP_ERROR SendIndication(BLE_CONNECTION_OBJECT connObj, const Ble::ChipBleUUID * svcId, const Ble::ChipBleUUID * charId,
+                              System::PacketBufferHandle pBuf) override;
+    CHIP_ERROR SendWriteRequest(BLE_CONNECTION_OBJECT connObj, const Ble::ChipBleUUID * svcId, const Ble::ChipBleUUID * charId,
+                                System::PacketBufferHandle pBuf) override;
 };
 
 } // namespace Internal
diff --git a/src/platform/Darwin/BlePlatformDelegateImpl.mm b/src/platform/Darwin/BlePlatformDelegateImpl.mm
index d967ea41d2a686..1b5fe31c58f3bf 100644
--- a/src/platform/Darwin/BlePlatformDelegateImpl.mm
+++ b/src/platform/Darwin/BlePlatformDelegateImpl.mm
@@ -27,76 +27,61 @@
 
 #include <ble/Ble.h>
 #include <lib/support/logging/CHIPLogging.h>
-#include <platform/Darwin/BlePlatformDelegate.h>
+#include <platform/Darwin/BlePlatformDelegateImpl.h>
+#include <platform/Darwin/BleUtils.h>
 
-#import "MTRUUIDHelper.h"
+#import <CoreBluetooth/CoreBluetooth.h>
 
-using namespace ::chip;
-using namespace ::chip::Ble;
-using ::chip::System::PacketBufferHandle;
+using namespace chip::Ble;
+using chip::System::PacketBufferHandle;
 
 namespace chip {
 namespace DeviceLayer {
     namespace Internal {
-        CHIP_ERROR BlePlatformDelegateImpl::SubscribeCharacteristic(
-            BLE_CONNECTION_OBJECT connObj, const ChipBleUUID * svcId, const ChipBleUUID * charId)
-        {
-            CHIP_ERROR err = BLE_ERROR_GATT_SUBSCRIBE_FAILED;
 
-            if (nullptr == svcId || nullptr == charId) {
-                return err;
-            }
-
-            CBUUID * serviceId = [MTRUUIDHelper GetShortestServiceUUID:svcId];
-            CBUUID * characteristicId = [CBUUID UUIDWithData:[NSData dataWithBytes:charId->bytes length:sizeof(charId->bytes)]];
-            CBPeripheral * peripheral = (__bridge CBPeripheral *) connObj;
-
-            for (CBService * service in peripheral.services) {
-                if ([service.UUID.data isEqualToData:serviceId.data]) {
-                    for (CBCharacteristic * characteristic in service.characteristics) {
-                        if ([characteristic.UUID.data isEqualToData:characteristicId.data]) {
-                            err = CHIP_NO_ERROR;
-                            [peripheral setNotifyValue:true forCharacteristic:characteristic];
-                            break;
+        namespace {
+            CBCharacteristic * FindCharacteristic(CBPeripheral * peripheral, const ChipBleUUID * svcId, const ChipBleUUID * charId)
+            {
+                VerifyOrReturnValue(svcId != nullptr && charId != nullptr, nil);
+                CBUUID * cbSvcId = CBUUIDFromBleUUID(*svcId);
+                for (CBService * service in peripheral.services) {
+                    if ([service.UUID isEqual:cbSvcId]) {
+                        CBUUID * cbCharId = CBUUIDFromBleUUID(*charId);
+                        for (CBCharacteristic * characteristic in service.characteristics) {
+                            if ([characteristic.UUID isEqual:cbCharId]) {
+                                return characteristic;
+                            }
                         }
+                        break;
                     }
                 }
+                return nil;
             }
+        }
 
-            return err;
+        CHIP_ERROR BlePlatformDelegateImpl::SubscribeCharacteristic(
+            BLE_CONNECTION_OBJECT connObj, const ChipBleUUID * svcId, const ChipBleUUID * charId)
+        {
+            CBPeripheral * peripheral = CBPeripheralFromBleConnObject(connObj);
+            CBCharacteristic * characteristic = FindCharacteristic(peripheral, svcId, charId);
+            VerifyOrReturnError(characteristic != nil, BLE_ERROR_GATT_SUBSCRIBE_FAILED);
+            [peripheral setNotifyValue:YES forCharacteristic:characteristic];
+            return CHIP_NO_ERROR;
         }
 
         CHIP_ERROR BlePlatformDelegateImpl::UnsubscribeCharacteristic(
             BLE_CONNECTION_OBJECT connObj, const ChipBleUUID * svcId, const ChipBleUUID * charId)
         {
-            CHIP_ERROR err = BLE_ERROR_GATT_UNSUBSCRIBE_FAILED;
-            if (nullptr == svcId || nullptr == charId) {
-                return err;
-            }
-
-            CBUUID * serviceId = [MTRUUIDHelper GetShortestServiceUUID:svcId];
-            CBUUID * characteristicId = characteristicId = [CBUUID UUIDWithData:[NSData dataWithBytes:charId->bytes
-                                                                                               length:sizeof(charId->bytes)]];
-            CBPeripheral * peripheral = (__bridge CBPeripheral *) connObj;
-
-            for (CBService * service in peripheral.services) {
-                if ([service.UUID.data isEqualToData:serviceId.data]) {
-                    for (CBCharacteristic * characteristic in service.characteristics) {
-                        if ([characteristic.UUID.data isEqualToData:characteristicId.data]) {
-                            err = CHIP_NO_ERROR;
-                            [peripheral setNotifyValue:false forCharacteristic:characteristic];
-                            break;
-                        }
-                    }
-                }
-            }
-
-            return err;
+            CBPeripheral * peripheral = CBPeripheralFromBleConnObject(connObj);
+            CBCharacteristic * characteristic = FindCharacteristic(peripheral, svcId, charId);
+            VerifyOrReturnError(characteristic != nil, BLE_ERROR_GATT_UNSUBSCRIBE_FAILED);
+            [peripheral setNotifyValue:NO forCharacteristic:characteristic];
+            return CHIP_NO_ERROR;
         }
 
         CHIP_ERROR BlePlatformDelegateImpl::CloseConnection(BLE_CONNECTION_OBJECT connObj)
         {
-            CBPeripheral * peripheral = (__bridge CBPeripheral *) connObj;
+            CBPeripheral * peripheral = CBPeripheralFromBleConnObject(connObj);
 
             // CoreBluetooth API requires a CBCentralManager to close a connection which is a property of the peripheral.
             CBCentralManager * manager = (CBCentralManager *) [peripheral valueForKey:@"manager"];
@@ -108,7 +93,7 @@
 
         uint16_t BlePlatformDelegateImpl::GetMTU(BLE_CONNECTION_OBJECT connObj) const
         {
-            CBPeripheral * peripheral = (__bridge CBPeripheral *) connObj;
+            CBPeripheral * peripheral = CBPeripheralFromBleConnObject(connObj);
 
             // The negotiated mtu length is a property of the peripheral.
             uint16_t mtuLength = [[peripheral valueForKey:@"mtuLength"] unsignedShortValue];
@@ -126,32 +111,12 @@
         CHIP_ERROR BlePlatformDelegateImpl::SendWriteRequest(
             BLE_CONNECTION_OBJECT connObj, const ChipBleUUID * svcId, const ChipBleUUID * charId, PacketBufferHandle pBuf)
         {
-            CHIP_ERROR err = BLE_ERROR_GATT_WRITE_FAILED;
-            if (nullptr == svcId || nullptr == charId || pBuf.IsNull()) {
-                return err;
-            }
-
-            CBUUID * serviceId = [MTRUUIDHelper GetShortestServiceUUID:svcId];
-            CBUUID * characteristicId = [CBUUID UUIDWithData:[NSData dataWithBytes:charId->bytes length:sizeof(charId->bytes)]];
-            NSData * data = [NSData dataWithBytes:pBuf->Start() length:pBuf->DataLength()];
-            CBPeripheral * peripheral = (__bridge CBPeripheral *) connObj;
-
-            for (CBService * service in peripheral.services) {
-                if ([service.UUID.data isEqualToData:serviceId.data]) {
-                    for (CBCharacteristic * characteristic in service.characteristics) {
-                        if ([characteristic.UUID.data isEqualToData:characteristicId.data]) {
-                            err = CHIP_NO_ERROR;
-                            [peripheral writeValue:data forCharacteristic:characteristic type:CBCharacteristicWriteWithResponse];
-                            break;
-                        }
-                    }
-                }
-            }
-
-            // Going out of scope releases delegate's reference to pBuf. pBuf will be freed when both platform delegate and Chip
-            // stack free their references to it. We release pBuf's reference here since its payload bytes were copied into a new
-            // NSData object
-            return err;
+            CBPeripheral * peripheral = CBPeripheralFromBleConnObject(connObj);
+            CBCharacteristic * characteristic = FindCharacteristic(peripheral, svcId, charId);
+            VerifyOrReturnError(characteristic != nil && !pBuf.IsNull(), BLE_ERROR_GATT_WRITE_FAILED);
+            NSData * data = [NSData dataWithBytes:pBuf->Start() length:pBuf->DataLength()]; // copies data, pBuf can be freed
+            [peripheral writeValue:data forCharacteristic:characteristic type:CBCharacteristicWriteWithResponse];
+            return CHIP_NO_ERROR;
         }
 
     } // namespace Internal
diff --git a/src/platform/Darwin/BleUtils.h b/src/platform/Darwin/BleUtils.h
new file mode 100644
index 00000000000000..13d7b4e88aecfb
--- /dev/null
+++ b/src/platform/Darwin/BleUtils.h
@@ -0,0 +1,46 @@
+/**
+ *    Copyright (c) 2025 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 <ble/Ble.h>
+
+@class CBPeripheral;
+@class CBUUID;
+
+namespace chip {
+namespace DeviceLayer {
+    namespace Internal {
+
+        inline CBPeripheral * CBPeripheralFromBleConnObject(BLE_CONNECTION_OBJECT connObj)
+        {
+            return (__bridge CBPeripheral *) connObj;
+        }
+
+        inline BLE_CONNECTION_OBJECT BleConnObjectFromCBPeripheral(CBPeripheral * peripheral)
+        {
+            return (__bridge void *) peripheral;
+        }
+
+        // Creates a CBUUID from a ChipBleUUID
+        CBUUID * CBUUIDFromBleUUID(Ble::ChipBleUUID const & uuid);
+
+        // Creates a ChipBleUUID from a CBUUID, expanding 16 or 32 bit UUIDs if necessary.
+        Ble::ChipBleUUID BleUUIDFromCBUUD(CBUUID * uuid);
+
+    }
+}
+}
diff --git a/src/platform/Darwin/BleUtils.mm b/src/platform/Darwin/BleUtils.mm
new file mode 100644
index 00000000000000..5ca9f4a3946c67
--- /dev/null
+++ b/src/platform/Darwin/BleUtils.mm
@@ -0,0 +1,66 @@
+/**
+ *    Copyright (c) 2025 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 "BleUtils.h"
+
+#import <CoreBluetooth/CoreBluetooth.h>
+#import <Foundation/Foundation.h>
+
+using namespace chip::Ble;
+
+namespace chip {
+namespace DeviceLayer {
+    namespace Internal {
+
+        CBUUID * CBUUIDFromBleUUID(ChipBleUUID const & uuid)
+        {
+            return [CBUUID UUIDWithData:[NSData dataWithBytes:uuid.bytes length:sizeof(uuid.bytes)]];
+        }
+
+        ChipBleUUID BleUUIDFromCBUUD(CBUUID * uuid)
+        {
+            // CBUUID handles the expansion to 128 bit automatically internally,
+            // but doesn't expose the expanded UUID in the `data` property, so
+            // we have to re-implement the expansion here.
+            // The Base UUID 00000000-0000-1000-8000-00805F9B34FB is defined in the BLE spec.
+            constexpr ChipBleUUID baseUuid = { { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10, 0x00, 0x80, 0x00, 0x00, 0x80, 0x5F, 0x9B, 0x34, 0xFB } };
+            NSData * uuidData = uuid.data;
+            switch (uuidData.length) {
+            case 2: {
+                ChipBleUUID outUuid = baseUuid;
+                memcpy(outUuid.bytes + 2, uuidData.bytes, 2);
+                return outUuid;
+            }
+            case 4: {
+                ChipBleUUID outUuid = baseUuid;
+                memcpy(outUuid.bytes, uuidData.bytes, 4);
+                return outUuid;
+            }
+            case 16: {
+                ChipBleUUID outUuid;
+                memcpy(outUuid.bytes, uuidData.bytes, 16);
+                return outUuid;
+            }
+            default: {
+                NSCAssert(NO, @"Invalid CBUUID.data: %@", uuidData);
+                return ChipBleUUID {};
+            }
+            }
+        }
+
+    }
+}
+}
diff --git a/src/platform/Darwin/MTRUUIDHelper.h b/src/platform/Darwin/MTRUUIDHelper.h
deleted file mode 100644
index faa0af95809f88..00000000000000
--- a/src/platform/Darwin/MTRUUIDHelper.h
+++ /dev/null
@@ -1,26 +0,0 @@
-/*
- *
- *    Copyright (c) 2020 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 <ble/Ble.h>
-
-#import <CoreBluetooth/CoreBluetooth.h>
-
-@interface MTRUUIDHelper : NSObject
-+ (CBUUID *)GetShortestServiceUUID:(const chip::Ble::ChipBleUUID *)svcId;
-@end
diff --git a/src/platform/Darwin/MTRUUIDHelperImpl.mm b/src/platform/Darwin/MTRUUIDHelperImpl.mm
deleted file mode 100644
index 8f1b863a760cf8..00000000000000
--- a/src/platform/Darwin/MTRUUIDHelperImpl.mm
+++ /dev/null
@@ -1,44 +0,0 @@
-/*
- *
- *    Copyright (c) 2020 Project CHIP Authors
- *    Copyright (c) 2015-2017 Nest Labs, Inc.
- *
- *    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.
- */
-
-#if !__has_feature(objc_arc)
-#error This file must be compiled with ARC. Use -fobjc-arc flag (or convert project to ARC).
-#endif
-
-#import "MTRUUIDHelper.h"
-
-@implementation MTRUUIDHelper
-
-+ (CBUUID *)GetShortestServiceUUID:(const chip::Ble::ChipBleUUID *)svcId
-{
-    // shorten the 16-byte UUID reported by BLE Layer to shortest possible, 2 or 4 bytes
-    // this is the BLE Service UUID Base. If a 16-byte service UUID partially matches with these 12 bytes,
-    // it can be shortened to 2 or 4 bytes.
-    static const uint8_t bleBaseUUID[12] = { 0x00, 0x00, 0x10, 0x00, 0x80, 0x00, 0x00, 0x80, 0x5F, 0x9B, 0x34, 0xFB };
-    if (0 == memcmp(svcId->bytes + 4, bleBaseUUID, sizeof(bleBaseUUID))) {
-        // okay, let's try to shorten it
-        if ((0 == svcId->bytes[0]) && (0 == svcId->bytes[1])) {
-            // the highest 2 bytes are both 0, so we just need 2 bytes
-            return [CBUUID UUIDWithData:[NSData dataWithBytes:(svcId->bytes + 2) length:2]];
-        } // we need to use 4 bytes
-        return [CBUUID UUIDWithData:[NSData dataWithBytes:svcId->bytes length:4]];
-    }
-    // it cannot be shortened as it doesn't match with the BLE Service UUID Base
-    return [CBUUID UUIDWithData:[NSData dataWithBytes:svcId->bytes length:16]];
-}
-@end