Provide an estimate of subscription latency in MTRDevice. (#33490)

The idea is to track how long subscriptions took in the past and surface an
estimate based on that to the API consumer.
diff --git a/src/darwin/Framework/CHIP/MTRDevice.h b/src/darwin/Framework/CHIP/MTRDevice.h
index a8fc45a..d9555a9 100644
--- a/src/darwin/Framework/CHIP/MTRDevice.h
+++ b/src/darwin/Framework/CHIP/MTRDevice.h
@@ -102,6 +102,14 @@
 @property (nonatomic, readonly, copy) NSNumber * nodeID NS_REFINED_FOR_SWIFT MTR_AVAILABLE(ios(17.4), macos(14.4), watchos(10.4), tvos(17.4));
 
 /**
+ * An estimate of how much time is likely to elapse between setDelegate being
+ * called and the current device state (attributes, stored events) being known.
+ *
+ * nil if no such estimate is available.  Otherwise, the NSNumber stores an NSTimeInterval.
+ */
+@property (nonatomic, readonly, nullable, copy) NSNumber * estimatedSubscriptionLatency MTR_NEWLY_AVAILABLE;
+
+/**
  * Set the delegate to receive asynchronous callbacks about the device.
  *
  * The delegate will be called on the provided queue, for attribute reports, event reports, and device state changes.
diff --git a/src/darwin/Framework/CHIP/MTRDevice.mm b/src/darwin/Framework/CHIP/MTRDevice.mm
index 8801355..ff2187c 100644
--- a/src/darwin/Framework/CHIP/MTRDevice.mm
+++ b/src/darwin/Framework/CHIP/MTRDevice.mm
@@ -213,6 +213,7 @@
 
 static NSString * const sDataVersionKey = @"dataVersion";
 static NSString * const sAttributesKey = @"attributes";
+static NSString * const sLastInitialSubscribeLatencyKey = @"lastInitialSubscribeLatency";
 
 - (void)storeValue:(MTRDeviceDataValueDictionary _Nullable)value forAttribute:(NSNumber *)attribute
 {
@@ -414,6 +415,10 @@
     //   2. OnResubscriptionNeeded is called
     //   3. Subscription reset (including when getSessionForNode fails)
     MTRAsyncWorkCompletionBlock _subscriptionPoolWorkCompletionBlock;
+
+    // Tracking of initial subscribe latency.  When _initialSubscribeStart is
+    // nil, we are not tracking the latency.
+    NSDate * _Nullable _initialSubscribeStart;
 }
 
 - (instancetype)initWithNodeID:(NSNumber *)nodeID controller:(MTRDeviceController *)controller
@@ -705,6 +710,7 @@
     }
 
     if (setUpSubscription) {
+        _initialSubscribeStart = [NSDate now];
         if ([self _deviceUsesThread]) {
             [self _scheduleSubscriptionPoolWork:^{
                 std::lock_guard lock(self->_lock);
@@ -954,6 +960,18 @@
     // No need to monitor connectivity after subscription establishment
     [self _stopConnectivityMonitoring];
 
+    auto initialSubscribeStart = _initialSubscribeStart;
+    // We no longer need to track subscribe latency for this device.
+    _initialSubscribeStart = nil;
+
+    if (initialSubscribeStart != nil) {
+        // We want time interval from initialSubscribeStart to now, not the other
+        // way around.
+        NSTimeInterval subscriptionLatency = -[initialSubscribeStart timeIntervalSinceNow];
+        _estimatedSubscriptionLatency = @(subscriptionLatency);
+        [self _storePersistedDeviceData];
+    }
+
     os_unfair_lock_unlock(&self->_lock);
 
     os_unfair_lock_lock(&self->_timeSyncLock);
@@ -2884,6 +2902,50 @@
     }
 }
 
+- (void)_setLastInitialSubscribeLatency:(id)latency
+{
+    os_unfair_lock_assert_owner(&self->_lock);
+
+    if (![latency isKindOfClass:NSNumber.class]) {
+        // Unexpected value of some sort; just ignore it.
+        return;
+    }
+
+    _estimatedSubscriptionLatency = latency;
+}
+
+- (void)setPersistedDeviceData:(NSDictionary<NSString *, id> *)data
+{
+    MTR_LOG_INFO("%@ setPersistedDeviceData: %@", self, data);
+
+    std::lock_guard lock(_lock);
+
+    // For now the only data we care about is our initial subscribe latency.
+    id initialSubscribeLatency = data[sLastInitialSubscribeLatencyKey];
+    if (initialSubscribeLatency != nil) {
+        [self _setLastInitialSubscribeLatency:initialSubscribeLatency];
+    }
+}
+
+- (void)_storePersistedDeviceData
+{
+    os_unfair_lock_assert_owner(&self->_lock);
+
+    auto datastore = _deviceController.controllerDataStore;
+    if (datastore == nil) {
+        // No way to store.
+        return;
+    }
+
+    // For now the only data we have is our initial subscribe latency.
+    NSMutableDictionary<NSString *, id> * data = [NSMutableDictionary dictionary];
+    if (_estimatedSubscriptionLatency != nil) {
+        data[sLastInitialSubscribeLatencyKey] = _estimatedSubscriptionLatency;
+    }
+
+    [datastore storeDeviceData:[data copy] forNodeID:self.nodeID];
+}
+
 - (BOOL)deviceCachePrimed
 {
     std::lock_guard lock(_lock);
diff --git a/src/darwin/Framework/CHIP/MTRDeviceController.mm b/src/darwin/Framework/CHIP/MTRDeviceController.mm
index 278ccdf..9c4b0a4 100644
--- a/src/darwin/Framework/CHIP/MTRDeviceController.mm
+++ b/src/darwin/Framework/CHIP/MTRDeviceController.mm
@@ -966,7 +966,7 @@
         if (prefetchedClusterData.count) {
             [deviceToReturn setPersistedClusterData:prefetchedClusterData];
         }
-    } else {
+    } else if (_controllerDataStore) {
         // Load persisted cluster data if they exist.
         NSDictionary * clusterData = [_controllerDataStore getStoredClusterDataForNodeID:nodeID];
         MTR_LOG_INFO("Loaded %lu cluster data from storage for %@", static_cast<unsigned long>(clusterData.count), deviceToReturn);
@@ -975,6 +975,14 @@
         }
     }
 
+    // TODO: Figure out how to get the device data as part of our bulk-read bits.
+    if (_controllerDataStore) {
+        auto * deviceData = [_controllerDataStore getStoredDeviceDataForNodeID:nodeID];
+        if (deviceData.count) {
+            [deviceToReturn setPersistedDeviceData:deviceData];
+        }
+    }
+
     return deviceToReturn;
 }
 
diff --git a/src/darwin/Framework/CHIP/MTRDeviceControllerDataStore.h b/src/darwin/Framework/CHIP/MTRDeviceControllerDataStore.h
index dd9da5c..e0cf9d0 100644
--- a/src/darwin/Framework/CHIP/MTRDeviceControllerDataStore.h
+++ b/src/darwin/Framework/CHIP/MTRDeviceControllerDataStore.h
@@ -78,6 +78,16 @@
 - (void)clearStoredClusterDataForNodeID:(NSNumber *)nodeID;
 - (void)clearAllStoredClusterData;
 
+/**
+ * Storage for miscellaneous MTRDevice data that only needs to be stored
+ * locally.
+ *
+ * storeDeviceData uses data and nodeID async without copying, so callers should
+ * not modify the passed-in dictionary or nodeID.
+ */
+- (nullable NSDictionary<NSString *, id> *)getStoredDeviceDataForNodeID:(NSNumber *)nodeID;
+- (void)storeDeviceData:(NSDictionary<NSString *, id> *)data forNodeID:(NSNumber *)nodeID;
+
 @end
 
 NS_ASSUME_NONNULL_END
diff --git a/src/darwin/Framework/CHIP/MTRDeviceControllerDataStore.mm b/src/darwin/Framework/CHIP/MTRDeviceControllerDataStore.mm
index 1f515f2..7b65f8e 100644
--- a/src/darwin/Framework/CHIP/MTRDeviceControllerDataStore.mm
+++ b/src/darwin/Framework/CHIP/MTRDeviceControllerDataStore.mm
@@ -1020,6 +1020,65 @@
     });
 }
 
+static NSString * sDeviceDataKeyPrefix = @"deviceData";
+
+- (NSString *)_deviceDataKeyForNodeID:(NSNumber *)nodeID
+{
+    return [sDeviceDataKeyPrefix stringByAppendingFormat:@":0x%016llX", nodeID.unsignedLongLongValue];
+}
+
+- (nullable NSDictionary<NSString *, id> *)getStoredDeviceDataForNodeID:(NSNumber *)nodeID
+{
+    __block NSDictionary<NSString *, id> * deviceData = nil;
+    dispatch_sync(_storageDelegateQueue, ^{
+        MTRDeviceController * controller = self->_controller;
+        VerifyOrReturn(controller != nil); // No way to call delegate without controller.
+
+        id data;
+        @autoreleasepool {
+            data = [self->_storageDelegate controller:controller
+                                          valueForKey:[self _deviceDataKeyForNodeID:nodeID]
+                                        securityLevel:MTRStorageSecurityLevelSecure
+                                          sharingType:MTRStorageSharingTypeNotShared];
+        }
+        if (data == nil) {
+            return;
+        }
+
+        if (![data isKindOfClass:NSDictionary.class]) {
+            return;
+        }
+
+        // Check that all the keys are in fact strings.
+        NSDictionary * dictionary = data;
+        for (id key in dictionary) {
+            if (![key isKindOfClass:NSString.class]) {
+                return;
+            }
+        }
+
+        // We can't do value type verification; our API consumer will need
+        // to do that.
+        deviceData = dictionary;
+    });
+    return deviceData;
+}
+
+- (void)storeDeviceData:(NSDictionary<NSString *, id> *)data forNodeID:(NSNumber *)nodeID
+{
+    dispatch_async(_storageDelegateQueue, ^{
+        MTRDeviceController * controller = self->_controller;
+        VerifyOrReturn(controller != nil); // No way to call delegate without controller.
+
+        // Ignore store failures, since they are not actionable for us here.
+        [self->_storageDelegate controller:controller
+                                storeValue:data
+                                    forKey:[self _deviceDataKeyForNodeID:nodeID]
+                             securityLevel:MTRStorageSecurityLevelSecure
+                               sharingType:MTRStorageSharingTypeNotShared];
+    });
+}
+
 @end
 
 @implementation MTRCASESessionResumptionInfo
diff --git a/src/darwin/Framework/CHIP/MTRDevice_Internal.h b/src/darwin/Framework/CHIP/MTRDevice_Internal.h
index 7302a5b..04313a6 100644
--- a/src/darwin/Framework/CHIP/MTRDevice_Internal.h
+++ b/src/darwin/Framework/CHIP/MTRDevice_Internal.h
@@ -86,6 +86,9 @@
 //   Contains data version information and attribute values.
 - (void)setPersistedClusterData:(NSDictionary<MTRClusterPath *, MTRDeviceClusterData *> *)clusterData;
 
+// Method to insert persisted data that pertains to the whole device.
+- (void)setPersistedDeviceData:(NSDictionary<NSString *, id> *)data;
+
 #ifdef DEBUG
 - (NSUInteger)unitTestAttributeCount;
 #endif
diff --git a/src/darwin/Framework/CHIPTests/MTRPerControllerStorageTests.m b/src/darwin/Framework/CHIPTests/MTRPerControllerStorageTests.m
index 49679dc..1940372 100644
--- a/src/darwin/Framework/CHIPTests/MTRPerControllerStorageTests.m
+++ b/src/darwin/Framework/CHIPTests/MTRPerControllerStorageTests.m
@@ -1362,10 +1362,18 @@
         [subscriptionExpectation fulfill];
     };
 
+    // Verify that initially (before we have ever subscribed while using this
+    // datastore) the device has no estimate for subscription latency.
+    XCTAssertNil(device.estimatedSubscriptionLatency);
+
+    __auto_type * beforeSetDelegate = [NSDate now];
+
     [device setDelegate:delegate queue:queue];
 
     [self waitForExpectations:@[ subscriptionExpectation ] timeout:60];
 
+    __auto_type * afterInitialSubscription = [NSDate now];
+
     NSUInteger dataStoreValuesCount = 0;
     NSDictionary<MTRClusterPath *, MTRDeviceClusterData *> * dataStoreClusterData = [controller.controllerDataStore getStoredClusterDataForNodeID:deviceID];
     for (MTRClusterPath * path in dataStoreClusterData) {
@@ -1400,6 +1408,21 @@
     double storedAttributeDifferFromMTRDevicePercentage = storedAttributeDifferFromMTRDeviceCount * 100.0 / dataStoreValuesCount;
     XCTAssertTrue(storedAttributeDifferFromMTRDevicePercentage < 10.0);
 
+    // Check that the new device has an estimated subscription latency.
+    XCTAssertNotNil(device.estimatedSubscriptionLatency);
+
+    // Check that this estimate is positive, since subscribing must have taken
+    // some time.
+    XCTAssertGreaterThan(device.estimatedSubscriptionLatency.doubleValue, 0);
+
+    // Check that this estimate is no larger than the measured latency observed
+    // above.  Unfortunately, We measure our observed latency to report end, not
+    // to the immediately following internal subscription established
+    // notification, so in fact our measured value can end up shorter than the
+    // estimated latency the device has.  Add some slop to handle that.
+    const NSTimeInterval timingSlopInSeconds = 0.1;
+    XCTAssertLessThanOrEqual(device.estimatedSubscriptionLatency.doubleValue, [afterInitialSubscription timeIntervalSinceDate:beforeSetDelegate] + timingSlopInSeconds);
+
     // Now set up new delegate for the new device and verify that once subscription reestablishes, the data version filter loaded from storage will work
     __auto_type * newDelegate = [[MTRDeviceTestDelegate alloc] init];