From 335db2deb009056037883daa3b16bb77faaf80e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Pantalea=CC=83o?= Date: Wed, 28 Feb 2024 14:36:14 +0100 Subject: [PATCH 1/3] Replace get_states by entities subscription --- CHANGELOG.md | 2 + HAKit.podspec | 2 +- Source/Caches/HACache.swift | 7 + Source/Caches/HACacheKeyStates.swift | 65 +++++ Source/Caches/HACachedStates.swift | 15 -- Source/Convenience/States.swift | 10 +- Source/Data/HACompressedEntity.swift | 58 +++++ Source/Data/HADecodeTransformable.swift | 14 +- Source/Data/HAEntity+CompressedEntity.swift | 23 ++ Source/Data/HAEntity.swift | 25 +- Source/Requests/HARequestType.swift | 2 + Tests/HACacheKeyStates.test.swift | 263 ++++++++++++++++++++ Tests/HACachedStates.test.swift | 172 +++++++------ Tests/HAEntity+CompressedEntity.test.swift | 111 +++++++++ Tests/States.test.swift | 6 +- 15 files changed, 650 insertions(+), 125 deletions(-) create mode 100644 Source/Caches/HACacheKeyStates.swift create mode 100644 Source/Data/HACompressedEntity.swift create mode 100644 Source/Data/HAEntity+CompressedEntity.swift create mode 100644 Tests/HACacheKeyStates.test.swift create mode 100644 Tests/HAEntity+CompressedEntity.test.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 550be16..52b9c4d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Changed: `HAData` now includes a `primitive` case to express non-array/dictionary values that aren't `null`. - Changed: WebSocket connection will now enable compression. - Fixed: Calling `HAConnection.connect()` and `HAConnection.disconnect()` off the main thread no longer occasionally crashes. +- Removed: Usage of "get_states" +- Added: More efficient API "subscribe_entities" replacing "get_states" ## [0.3] - 2021-07-08 - Added: Subscriptions will now retry (when their request `shouldRetry`) when the HA config changes or components are loaded. diff --git a/HAKit.podspec b/HAKit.podspec index 05fb042..7c060de 100644 --- a/HAKit.podspec +++ b/HAKit.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'HAKit' - s.version = '0.3' + s.version = '0.4' s.summary = 'Communicate with a Home Assistant instance.' s.author = 'Home Assistant' diff --git a/Source/Caches/HACache.swift b/Source/Caches/HACache.swift index c854a5c..ff0884d 100644 --- a/Source/Caches/HACache.swift +++ b/Source/Caches/HACache.swift @@ -44,6 +44,7 @@ public class HACache { self.connection = connection self.populateInfo = populate self.subscribeInfo = subscribe + self.subscribeOnlyInfo = nil self.start = { connection, cache, _ in Self.startPopulate(for: populate, on: connection, cache: cache) { cacheResult in @@ -82,6 +83,7 @@ public class HACache { self.connection = connection self.populateInfo = nil self.subscribeInfo = nil + self.subscribeOnlyInfo = subscribe self.start = { connection, cache, state in state.isWaitingForPopulate = false @@ -107,6 +109,7 @@ public class HACache { self.connection = incomingCache.connection self.populateInfo = nil self.subscribeInfo = nil + self.subscribeOnlyInfo = nil self.start = { _, someCache, _ in // unfortunately, using this value directly crashes the swift compiler, so we call into it with this let cache: HACache = someCache @@ -136,6 +139,7 @@ public class HACache { } self.populateInfo = nil self.subscribeInfo = nil + self.subscribeOnlyInfo = nil state.mutate { state in state.current = constantValue } @@ -321,6 +325,9 @@ public class HACache { /// If this cache was created with subscribe info, this contains that info /// This is largely intended for tests and is not used internally. public let subscribeInfo: [HACacheSubscribeInfo]? + /// If this cache was created with subscribe info, this contains that info + /// This is largely intended for tests and is not used internally. + public let subscribeOnlyInfo: HACacheSubscribeInfo? /// Do the underlying populate send /// - Parameters: diff --git a/Source/Caches/HACacheKeyStates.swift b/Source/Caches/HACacheKeyStates.swift new file mode 100644 index 0000000..5ade9c3 --- /dev/null +++ b/Source/Caches/HACacheKeyStates.swift @@ -0,0 +1,65 @@ +import Foundation + +/// Key for the cache +internal struct HACacheKeyStates: HACacheKey { + static func create(connection: HAConnection) -> HACache { + .init( + connection: connection, + subscribe: + .init( + subscription: .subscribeEntities(), + transform: { info in + .replace(processUpdates(info: info)) + } + ) + ) + } + + /// Process updates from the compressed state to HAEntity + /// - Parameter info: The compressed state update and the current cached states + /// - Returns: HAEntity cached states + /// Logic from: https://github.com/home-assistant/home-assistant-js-websocket/blob/master/lib/entities.ts + static func processUpdates(info: HACacheTransformInfo) + -> HACachedStates { + var states: HACachedStates = info.current ?? .init(entities: []) + + if let additions = info.incoming.add { + for (entityId, updates) in additions { + if var currentState = states[entityId] { + currentState.update(from: updates) + states[entityId] = currentState + } else { + do { + states[entityId] = try updates.asEntity(entityId: entityId) + } catch { + HAGlobal.log(.error, "[Update-To-Entity-Error] Failed adding new entity: \(error)") + } + } + } + } + + if let subtractions = info.incoming.remove { + for entityId in subtractions { + states[entityId] = nil + } + } + + if let changes = info.incoming.change { + changes.forEach { entityId, diff in + guard var entityState = states[entityId] else { return } + + if let toAdd = diff.additions { + entityState.add(toAdd) + states[entityId] = entityState + } + + if let toRemove = diff.subtractions { + entityState.subtract(toRemove) + states[entityId] = entityState + } + } + } + + return states + } +} diff --git a/Source/Caches/HACachedStates.swift b/Source/Caches/HACachedStates.swift index f16b3a0..2795dc6 100644 --- a/Source/Caches/HACachedStates.swift +++ b/Source/Caches/HACachedStates.swift @@ -3,21 +3,6 @@ public extension HACachesContainer { var states: HACache { self[HACacheKeyStates.self] } } -/// Key for the cache -private struct HACacheKeyStates: HACacheKey { - static func create(connection: HAConnection) -> HACache { - .init( - connection: connection, - populate: .init(request: .getStates(), transform: { .init(entities: $0.incoming) }), - subscribe: .init(subscription: .stateChanged(), transform: { info in - var updated = info.current - updated[info.incoming.entityId] = info.incoming.newState - return .replace(updated) - }) - ) - } -} - /// Cached version of all entity states public struct HACachedStates { /// All entities diff --git a/Source/Convenience/States.swift b/Source/Convenience/States.swift index 1fc9749..401f6e9 100644 --- a/Source/Convenience/States.swift +++ b/Source/Convenience/States.swift @@ -9,13 +9,11 @@ public extension HATypedSubscription { "event_type": HAEventType.stateChanged.rawValue!, ])) } -} -public extension HATypedRequest { - /// Get the state of all entities - /// - Returns: A typed request that can be sent via `HAConnection` - static func getStates() -> HATypedRequest<[HAEntity]> { - .init(request: .init(type: .getStates, data: [:])) + /// Listen for compressed state changes of all entities + /// - Returns: A typed subscriptions that can be sent via `HAConnection` + static func subscribeEntities() -> HATypedSubscription { + .init(request: .init(type: .subscribeEntities, data: [:])) } } diff --git a/Source/Data/HACompressedEntity.swift b/Source/Data/HACompressedEntity.swift new file mode 100644 index 0000000..364deab --- /dev/null +++ b/Source/Data/HACompressedEntity.swift @@ -0,0 +1,58 @@ +import Foundation + +public struct HACompressedStatesUpdates: HADataDecodable { + public var add: [String: HACompressedEntityState]? + public var remove: [String]? + public var change: [String: HACompressedEntityDiff]? + + public init(data: HAData) throws { + self.add = try? data.decode("a") + self.remove = try? data.decode("r") + self.change = try? data.decode("c") + } +} + +public struct HACompressedEntityState: HADataDecodable { + public var state: String + public var attributes: [String: Any]? + public var context: String? + public var lastChanged: Date? + public var lastUpdated: Date? + + public init(data: HAData) throws { + self.state = try data.decode("s") + self.attributes = try? data.decode("a") + self.context = try? data.decode("c") + self.lastChanged = try? data.decode("lc") + self.lastUpdated = try? data.decode("lu") + } + + func asEntity(entityId: String) throws -> HAEntity { + try HAEntity( + entityId: entityId, + state: state, + lastChanged: lastChanged ?? Date(), + lastUpdated: lastUpdated ?? Date(), + attributes: attributes ?? [:], + context: .init(id: context ?? "", userId: nil, parentId: nil) + ) + } +} + +public struct HACompressedEntityStateRemove: HADataDecodable { + public var attributes: [String]? + + public init(data: HAData) throws { + self.attributes = try? data.decode("a") + } +} + +public struct HACompressedEntityDiff: HADataDecodable { + public var additions: HACompressedEntityState? + public var subtractions: HACompressedEntityStateRemove? + + public init(data: HAData) throws { + self.additions = try? data.decode("+") + self.subtractions = try? data.decode("-") + } +} diff --git a/Source/Data/HADecodeTransformable.swift b/Source/Data/HADecodeTransformable.swift index bf0477a..6509570 100644 --- a/Source/Data/HADecodeTransformable.swift +++ b/Source/Data/HADecodeTransformable.swift @@ -76,12 +76,18 @@ extension Date: HADecodeTransformable { /// - Parameter value: A string value to convert /// - Returns: The value converted to a Date, or nil if not possible public static func decode(unknown value: Any) -> Self? { - guard let value = value as? String else { return nil } - for formatter in Self.formatters { - if let string = formatter.date(from: value) { - return string + if let timestamp = value as? Double { + return Date(timeIntervalSince1970: timestamp) + } + + if let value = value as? String { + for formatter in Self.formatters { + if let string = formatter.date(from: value) { + return string + } } } + return nil } } diff --git a/Source/Data/HAEntity+CompressedEntity.swift b/Source/Data/HAEntity+CompressedEntity.swift new file mode 100644 index 0000000..07c1862 --- /dev/null +++ b/Source/Data/HAEntity+CompressedEntity.swift @@ -0,0 +1,23 @@ +import Foundation + +extension HAEntity { + mutating func update(from state: HACompressedEntityState) { + self.state = state.state + lastChanged = state.lastChanged ?? lastChanged + lastUpdated = state.lastUpdated ?? lastUpdated + attributes.dictionary = state.attributes ?? attributes.dictionary + context = .init(id: state.context ?? "", userId: nil, parentId: nil) + } + + mutating func add(_ state: HACompressedEntityState) { + self.state = state.state + lastChanged = state.lastChanged ?? lastChanged + lastUpdated = state.lastUpdated ?? lastUpdated + attributes.dictionary.merge(state.attributes ?? [:]) { current, _ in current } + context = .init(id: state.context ?? "", userId: nil, parentId: nil) + } + + mutating func subtract(_ state: HACompressedEntityStateRemove) { + attributes.dictionary = attributes.dictionary.filter { !(state.attributes?.contains($0.key) ?? false) } + } +} diff --git a/Source/Data/HAEntity.swift b/Source/Data/HAEntity.swift index 07a9d36..f733ff8 100644 --- a/Source/Data/HAEntity.swift +++ b/Source/Data/HAEntity.swift @@ -25,13 +25,7 @@ public struct HAEntity: HADataDecodable, Hashable { try self.init( entityId: entityId, - domain: { - guard let dot = entityId.firstIndex(of: ".") else { - throw HADataError.couldntTransform(key: "entity_id") - } - - return String(entityId[.. Bool { lhs.lastUpdated == rhs.lastUpdated && lhs.entityId == rhs.entityId && lhs.state == rhs.state } + + public static func domain(from entityId: String) throws -> String { + guard let dot = entityId.firstIndex(of: ".") else { + throw HADataError.couldntTransform(key: "entity_id") + } + + return String(entityId[...getStates() - XCTAssertEqual(request.request.type, .getStates) + func testSubscribeEntitiesRequest() throws { + let request = HATypedSubscription.subscribeEntities() + XCTAssertEqual(request.request.type, .subscribeEntities) } } From 26c5f3755731c6dc5bd32fa7fad4e4a7878f3110 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Pantalea=CC=83o?= Date: Thu, 29 Feb 2024 10:27:55 +0100 Subject: [PATCH 2/3] Prevent subscription process update from having stale data --- Source/Caches/HACacheKeyStates.swift | 22 +++++++++++++++++----- Source/Caches/HACacheSubscribeInfo.swift | 24 ++++++++++++++++++++---- Source/Caches/HACacheTransformInfo.swift | 11 +++++++++++ Tests/HACacheKeyStates.test.swift | 8 ++++---- Tests/HACacheSubscribeInfo.test.swift | 12 ++++++++---- Tests/HACachedStates.test.swift | 9 ++++++--- 6 files changed, 66 insertions(+), 20 deletions(-) diff --git a/Source/Caches/HACacheKeyStates.swift b/Source/Caches/HACacheKeyStates.swift index 5ade9c3..a4761d7 100644 --- a/Source/Caches/HACacheKeyStates.swift +++ b/Source/Caches/HACacheKeyStates.swift @@ -9,20 +9,32 @@ internal struct HACacheKeyStates: HACacheKey { .init( subscription: .subscribeEntities(), transform: { info in - .replace(processUpdates(info: info)) + .replace(processUpdates( + info: info, + shouldResetEntities: info.subscriptionPhase == .initial + )) } ) ) } /// Process updates from the compressed state to HAEntity - /// - Parameter info: The compressed state update and the current cached states + /// - Parameters: + /// - info: The compressed state update and the current cached states + /// - shouldResetEntities: True if current state needs to be ignored (e.g. re-connection) /// - Returns: HAEntity cached states /// Logic from: https://github.com/home-assistant/home-assistant-js-websocket/blob/master/lib/entities.ts - static func processUpdates(info: HACacheTransformInfo) + static func processUpdates( + info: HACacheTransformInfo, + shouldResetEntities: Bool + ) -> HACachedStates { - var states: HACachedStates = info.current ?? .init(entities: []) - + var states: HACachedStates + if shouldResetEntities { + states = .init(entities: []) + } else { + states = info.current ?? .init(entities: []) + } if let additions = info.incoming.add { for (entityId, updates) in additions { if var currentState = states[entityId] { diff --git a/Source/Caches/HACacheSubscribeInfo.swift b/Source/Caches/HACacheSubscribeInfo.swift index 075b8e6..866b93b 100644 --- a/Source/Caches/HACacheSubscribeInfo.swift +++ b/Source/Caches/HACacheSubscribeInfo.swift @@ -36,9 +36,16 @@ public struct HACacheSubscribeInfo { return transform(value) }, start: { connection, perform in - connection.subscribe(to: nonRetrySubscription, handler: { _, incoming in + var operationType: HACacheSubscriptionPhase = .initial + return connection.subscribe(to: nonRetrySubscription, handler: { _, incoming in perform { current in - transform(.init(incoming: incoming, current: current)) + let transform = transform(.init( + incoming: incoming, + current: current, + subscriptionPhase: operationType + )) + operationType = .iteration + return transform } }) } @@ -63,10 +70,19 @@ public struct HACacheSubscribeInfo { /// - Parameters: /// - incoming: The incoming value, of some given type -- intended to be the IncomingType that created this /// - current: The current value part of the transform info + /// - subscriptionPhase: The phase in which the subscription is, initial iteration or subsequent /// - Throws: If the type of incoming does not match the original IncomingType /// - Returns: The response from the transform block - public func transform(incoming: IncomingType, current: OutgoingType) throws -> Response { - try anyTransform(HACacheTransformInfo(incoming: incoming, current: current)) + public func transform( + incoming: IncomingType, + current: OutgoingType, + subscriptionPhase: HACacheSubscriptionPhase + ) throws -> Response { + try anyTransform(HACacheTransformInfo( + incoming: incoming, + current: current, + subscriptionPhase: subscriptionPhase + )) } /// The start handler diff --git a/Source/Caches/HACacheTransformInfo.swift b/Source/Caches/HACacheTransformInfo.swift index f235d53..20a2efb 100644 --- a/Source/Caches/HACacheTransformInfo.swift +++ b/Source/Caches/HACacheTransformInfo.swift @@ -9,4 +9,15 @@ public struct HACacheTransformInfo { /// For populate transforms, this is nil if an initial request hasn't been sent yet and the cache not reset. /// For subscribe transforms, this is nil if the populate did not produce results (or does not exist). public var current: OutgoingType + + /// The current phase of the subscription + public var subscriptionPhase: HACacheSubscriptionPhase = .initial +} + +/// The subscription phases +public enum HACacheSubscriptionPhase { + /// `Initial` means it's the first time a value is returned + case initial + /// `Iteration` means subsequent iterations + case iteration } diff --git a/Tests/HACacheKeyStates.test.swift b/Tests/HACacheKeyStates.test.swift index c362384..d0189d5 100644 --- a/Tests/HACacheKeyStates.test.swift +++ b/Tests/HACacheKeyStates.test.swift @@ -57,7 +57,7 @@ internal final class HACacheKeyStates_test: XCTestCase { current: .init( entities: [] ) - ) + ), shouldResetEntities: false ) XCTAssertEqual(result.all.count, 2) @@ -137,7 +137,7 @@ internal final class HACacheKeyStates_test: XCTestCase { existentEntity, ] ) - ) + ), shouldResetEntities: false ) XCTAssertEqual(result.all.count, 1) @@ -206,7 +206,7 @@ internal final class HACacheKeyStates_test: XCTestCase { existentEntity, ] ) - ) + ), shouldResetEntities: false ) XCTAssertEqual(result.all.count, 1) @@ -254,7 +254,7 @@ internal final class HACacheKeyStates_test: XCTestCase { current: .init( entities: [] ) - ) + ), shouldResetEntities: false ) XCTAssertEqual(result.all.count, 0) diff --git a/Tests/HACacheSubscribeInfo.test.swift b/Tests/HACacheSubscribeInfo.test.swift index 856d1f3..3c9de4f 100644 --- a/Tests/HACacheSubscribeInfo.test.swift +++ b/Tests/HACacheSubscribeInfo.test.swift @@ -27,14 +27,18 @@ internal class HACacheSubscribeInfoTests: XCTestCase { XCTAssertEqual(info.request.type, "test") XCTAssertEqual(info.request.data["in_data"] as? Bool, true) - XCTAssertThrowsError(try info.transform(incoming: "hello", current: item)) - XCTAssertThrowsError(try info.transform(incoming: SubscribeItem?.none, current: item)) + XCTAssertThrowsError(try info.transform(incoming: "hello", current: item, subscriptionPhase: .initial)) + XCTAssertThrowsError(try info.transform( + incoming: SubscribeItem?.none, + current: item, + subscriptionPhase: .iteration + )) result = .reissuePopulate - XCTAssertEqual(try info.transform(incoming: item, current: item), result) + XCTAssertEqual(try info.transform(incoming: item, current: item, subscriptionPhase: .iteration), result) result = .replace(SubscribeItem()) - XCTAssertEqual(try info.transform(incoming: item, current: item), result) + XCTAssertEqual(try info.transform(incoming: item, current: item, subscriptionPhase: .iteration), result) } func testNotRetryRequest() throws { diff --git a/Tests/HACachedStates.test.swift b/Tests/HACachedStates.test.swift index d3d6a12..2521656 100644 --- a/Tests/HACachedStates.test.swift +++ b/Tests/HACachedStates.test.swift @@ -55,7 +55,8 @@ internal class HACachedStatesTests: XCTestCase { """ ) ), - current: nil + current: nil, + subscriptionPhase: .initial ) guard case let .replace(outgoingType) = result1 else { XCTFail("Did not replace when expected") @@ -85,7 +86,8 @@ internal class HACachedStatesTests: XCTestCase { """ ) ), - current: .init(entities: Array(outgoingType!.all)) + current: .init(entities: Array(outgoingType!.all)), + subscriptionPhase: .iteration ) guard case let .replace(updatedOutgoingType) = updateEventResult else { @@ -112,7 +114,8 @@ internal class HACachedStatesTests: XCTestCase { """ ) ), - current: .init(entities: Array(updatedOutgoingType!.all)) + current: .init(entities: Array(outgoingType!.all)), + subscriptionPhase: .iteration ) guard case let .replace(entityRemovedOutgoingType) = entityRemovalEventResult else { From ab2f0f7a168662cda6fb6779c42cc319cadb6de2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Pantalea=CC=83o?= Date: Wed, 6 Mar 2024 10:58:01 +0100 Subject: [PATCH 3/3] PR improvements --- Source/Data/HAEntity.swift | 4 ++-- Tests/HACacheKeyStates.test.swift | 1 - 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/Source/Data/HAEntity.swift b/Source/Data/HAEntity.swift index f733ff8..4f69fe5 100644 --- a/Source/Data/HAEntity.swift +++ b/Source/Data/HAEntity.swift @@ -37,7 +37,7 @@ public struct HAEntity: HADataDecodable, Hashable { /// Create an entity from individual items /// - Parameters: /// - entityId: The entity ID - /// - domain: The domain of the entity ID + /// - domain: The domain of the entity ID (optional), when nil domain will be extracted from entityId /// - state: The state /// - lastChanged: The date last changed /// - lastUpdated: The date last updated @@ -78,7 +78,7 @@ public struct HAEntity: HADataDecodable, Hashable { lhs.lastUpdated == rhs.lastUpdated && lhs.entityId == rhs.entityId && lhs.state == rhs.state } - public static func domain(from entityId: String) throws -> String { + internal static func domain(from entityId: String) throws -> String { guard let dot = entityId.firstIndex(of: ".") else { throw HADataError.couldntTransform(key: "entity_id") } diff --git a/Tests/HACacheKeyStates.test.swift b/Tests/HACacheKeyStates.test.swift index d0189d5..eb1d14d 100644 --- a/Tests/HACacheKeyStates.test.swift +++ b/Tests/HACacheKeyStates.test.swift @@ -219,7 +219,6 @@ internal final class HACacheKeyStates_test: XCTestCase { func testProcessUpdatesAddNewEntitiesWhenEntityCantBeConvertedFromUpdate() throws { let expectation = expectation(description: "Wait for error log") - let expectedDate = Date(timeIntervalSince1970: 1_707_884_643.671705) HAGlobal.log = { level, message in XCTAssertEqual(level, .error)