├── codecov.yaml ├── .drstring.toml ├── .jazzy.yaml ├── .gitattributes ├── Brewfile ├── .github ├── dependabot.yml └── workflows │ ├── docs.yml │ └── ci.yml ├── .swiftpm └── xcode │ ├── package.xcworkspace │ └── contents.xcworkspacedata │ └── xcshareddata │ └── xcschemes │ └── HAKit.xcscheme ├── Source ├── HARequestToken.swift ├── Internal │ ├── HACancellableImpl.swift │ ├── RequestController │ │ ├── HARequestIdentifier.swift │ │ ├── HARequestInvocationSingle.swift │ │ ├── HARequestInvocation.swift │ │ └── HARequestInvocationSubscription.swift │ ├── HASchedulingTimer.swift │ ├── HAResetLock.swift │ ├── HAConnectionImpl+Requests.swift │ ├── HAStarscreamCertificatePinningImpl.swift │ ├── HAProtected.swift │ ├── ResponseController │ │ └── HAWebSocketResponse.swift │ ├── HAConnectionImpl+Responses.swift │ └── HAReconnectManager.swift ├── Requests │ ├── HAResponseVoid.swift │ ├── HATypedRequest.swift │ ├── HATypedSubscription.swift │ ├── HAHTTPMethod.swift │ ├── HASttData.swift │ ├── HARequest.swift │ └── HARequestType.swift ├── Caches │ ├── HACachedUser.swift │ ├── HACacheTransformInfo.swift │ ├── HACachedStates.swift │ ├── HACacheKeyStates.swift │ ├── HACachesContainer.swift │ ├── HACachePopulateInfo.swift │ └── HACacheSubscribeInfo.swift ├── HAGlobal.swift ├── HAKit.swift ├── HAConnectionConfiguration.swift ├── Data │ ├── HAEntity+CompressedEntity.swift │ ├── HAService.swift │ ├── HACompressedEntity.swift │ ├── HAData.swift │ ├── HADecodeTransformable.swift │ ├── HADataDecodable.swift │ └── HAEntity.swift ├── HAError.swift ├── Convenience │ ├── States.swift │ ├── RenderTemplate.swift │ ├── CurrentUser.swift │ ├── Services.swift │ └── Event.swift └── HAConnectionInfo.swift ├── Tests ├── HAResponseVoid.test.swift ├── HACancellableImpl.test.swift ├── HAKit.test.swift ├── TestAdditions.swift ├── FakeEngine.swift ├── HACachedUser.test.swift ├── HADataDecodable.test.swift ├── HAError.test.swift ├── StubbingURLProtocol.swift ├── HAResetLock.test.swift ├── HAWebSocketResponseFixture.swift ├── HARequestInvocationSingle.test.swift ├── HAConnectionConfiguration.test.swift ├── RenderTemplate.test.swift ├── Event.test.swift ├── CurrentUser.test.swift ├── HACachesContainer.test.swift ├── HARequestInvocationSubscription.test.swift ├── HAEntity+CompressedEntity.test.swift ├── HACacheSubscribeInfo.test.swift ├── HAWebSocketResponse.test.swift ├── HACachePopulateInfo.test.swift ├── HACachedStates.test.swift ├── States.test.swift └── HAEntity.test.swift ├── .swiftformat ├── Extensions └── PromiseKit │ ├── HACache+PromiseKit.swift │ └── HAConnection+PromiseKit.swift ├── Package.resolved ├── Makefile ├── .swiftlint.yml ├── .gitignore ├── HAKit.podspec ├── CONTRIBUTING.md ├── Package.swift ├── CLA.md ├── CHANGELOG.md └── CODE_OF_CONDUCT.md /codecov.yaml: -------------------------------------------------------------------------------- 1 | ignore: 2 | - Example 3 | - Tests 4 | - Extensions/Mocks 5 | -------------------------------------------------------------------------------- /.drstring.toml: -------------------------------------------------------------------------------- 1 | include = [ 2 | 'Source/**/*.swift', 3 | ] 4 | 5 | parameter-style = "grouped" 6 | -------------------------------------------------------------------------------- /.jazzy.yaml: -------------------------------------------------------------------------------- 1 | swift_build_tool: spm 2 | github_url: https://www.github.com/home-assistant/ha-swift-api 3 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # for the 'languages' composition on GitHub 2 | Makefile -linguist-detectable 3 | Brewfile -linguist-detectable 4 | *.podspec -linguist-detectable 5 | -------------------------------------------------------------------------------- /Brewfile: -------------------------------------------------------------------------------- 1 | brew 'dduan/formulae/drstring' 2 | brew 'swiftformat' unless File.exist?('/usr/local/bin/swiftformat') 3 | brew 'swiftlint' unless File.exist?('/usr/local/bin/swiftlint') 4 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: weekly 7 | time: "06:00" 8 | open-pull-requests-limit: 10 -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Source/HARequestToken.swift: -------------------------------------------------------------------------------- 1 | /// A token representing an individual request or subscription 2 | /// 3 | /// You do not need to strongly retain this value. Requests are only cancelled explicitly. 4 | public protocol HACancellable { 5 | /// Cancel the request or subscription represented by this. 6 | func cancel() 7 | } 8 | -------------------------------------------------------------------------------- /Source/Internal/HACancellableImpl.swift: -------------------------------------------------------------------------------- 1 | internal class HACancellableImpl: HACancellable { 2 | var handler: (() -> Void)? 3 | 4 | init(handler: @escaping () -> Void) { 5 | self.handler = handler 6 | } 7 | 8 | func cancel() { 9 | handler?() 10 | handler = nil 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /Source/Internal/RequestController/HARequestIdentifier.swift: -------------------------------------------------------------------------------- 1 | internal struct HARequestIdentifier: RawRepresentable, Hashable, ExpressibleByIntegerLiteral { 2 | let rawValue: Int 3 | 4 | init(integerLiteral value: Int) { 5 | self.rawValue = value 6 | } 7 | 8 | init(rawValue: Int) { 9 | self.rawValue = rawValue 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Source/Requests/HAResponseVoid.swift: -------------------------------------------------------------------------------- 1 | /// Type representing a response type that we do not care about 2 | /// 3 | /// Think of this like `Void` -- you don't care about it. 4 | /// 5 | /// - TODO: can we somehow get Void to work with the type system? it can't conform to decodable itself :/ 6 | public struct HAResponseVoid: HADataDecodable { 7 | public init(data: HAData) throws {} 8 | } 9 | -------------------------------------------------------------------------------- /Source/Requests/HATypedRequest.swift: -------------------------------------------------------------------------------- 1 | /// A request which has a strongly-typed response format 2 | public struct HATypedRequest { 3 | /// Create a typed request 4 | /// - Parameter request: The request to be issued 5 | public init(request: HARequest) { 6 | self.request = request 7 | } 8 | 9 | /// The request to be issued 10 | public var request: HARequest 11 | } 12 | -------------------------------------------------------------------------------- /Source/Requests/HATypedSubscription.swift: -------------------------------------------------------------------------------- 1 | /// A subscription request which has a strongly-typed handler 2 | public struct HATypedSubscription { 3 | /// Create a typed subscription 4 | /// - Parameter request: The request to be issued to start the subscription 5 | public init(request: HARequest) { 6 | self.request = request 7 | } 8 | 9 | /// The request to be issued 10 | public var request: HARequest 11 | } 12 | -------------------------------------------------------------------------------- /Tests/HAResponseVoid.test.swift: -------------------------------------------------------------------------------- 1 | @testable import HAKit 2 | import XCTest 3 | 4 | internal class HAResponseVoidTests: XCTestCase { 5 | func testWithDictionary() { 6 | XCTAssertNoThrow(try HAResponseVoid(data: .dictionary([:]))) 7 | } 8 | 9 | func testWithArray() { 10 | XCTAssertNoThrow(try HAResponseVoid(data: .array([]))) 11 | } 12 | 13 | func testWithEmpty() { 14 | XCTAssertNoThrow(try HAResponseVoid(data: .empty)) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /.swiftformat: -------------------------------------------------------------------------------- 1 | --swiftversion 5.3 2 | 3 | --exclude Example 4 | 5 | --wraparguments before-first 6 | --wrapparameters before-first 7 | --wrapcollections before-first 8 | --closingparen balanced 9 | 10 | --stripunusedargs closure-only 11 | 12 | --ifdef no-indent 13 | 14 | --disable wrapMultilineStatementBraces 15 | 16 | --maxwidth 120 17 | 18 | --self init-only 19 | --guardelse same-line 20 | --header strip 21 | --disable redundantRawValues 22 | --disable trailingclosures 23 | --disable redundantInternal 24 | -------------------------------------------------------------------------------- /Source/Internal/HASchedulingTimer.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | @propertyWrapper 4 | internal struct HASchedulingTimer { 5 | var wrappedValue: WrappedValue? { 6 | willSet { 7 | if wrappedValue != newValue { 8 | wrappedValue?.invalidate() 9 | } 10 | } 11 | 12 | didSet { 13 | if let timer = wrappedValue, timer != oldValue { 14 | RunLoop.main.add(timer, forMode: .common) 15 | } 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Extensions/PromiseKit/HACache+PromiseKit.swift: -------------------------------------------------------------------------------- 1 | #if SWIFT_PACKAGE 2 | import HAKit 3 | #endif 4 | import PromiseKit 5 | 6 | public extension HACache { 7 | /// Wrap a once subscription in a Guarantee 8 | /// 9 | /// - SeeAlso: `HACache.once(_:)` 10 | /// - Returns: The promies for the value, and a block to cancel 11 | func once() -> (promise: Guarantee, cancel: () -> Void) { 12 | let (guarantee, seal) = Guarantee.pending() 13 | let token = once(seal) 14 | return (promise: guarantee, cancel: token.cancel) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /Source/Caches/HACachedUser.swift: -------------------------------------------------------------------------------- 1 | public extension HACachesContainer { 2 | /// Cache of the current user. 3 | var user: HACache { self[HACacheKeyCurrentUser.self] } 4 | } 5 | 6 | /// Key for the cache 7 | private struct HACacheKeyCurrentUser: HACacheKey { 8 | static func create(connection: HAConnection, data: [String: Any]) -> HACache { 9 | .init( 10 | connection: connection, 11 | populate: .init(request: .currentUser(), transform: \.incoming), 12 | subscribe: [] 13 | ) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Source/Internal/HAResetLock.swift: -------------------------------------------------------------------------------- 1 | import Dispatch 2 | 3 | internal class HAResetLock { 4 | private var value: HAProtected 5 | 6 | init(value: Value?) { 7 | self.value = .init(value: value) 8 | } 9 | 10 | func reset() { 11 | value.mutate { value in 12 | value = nil 13 | } 14 | } 15 | 16 | func read() -> Value? { 17 | value.read { $0 } 18 | } 19 | 20 | func pop() -> Value? { 21 | value.mutate { value in 22 | let old = value 23 | value = nil 24 | return old 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /Source/Requests/HAHTTPMethod.swift: -------------------------------------------------------------------------------- 1 | public struct HAHTTPMethod: RawRepresentable, Hashable, ExpressibleByStringLiteral { 2 | public var rawValue: String 3 | public init(rawValue: String) { self.rawValue = rawValue } 4 | public init(stringLiteral value: StringLiteralType) { self.init(rawValue: value) } 5 | 6 | public static var get: Self = "GET" 7 | public static var post: Self = "POST" 8 | public static var delete: Self = "DELETE" 9 | public static var put: Self = "PUT" 10 | public static var patch: Self = "PATCH" 11 | public static var head: Self = "HEAD" 12 | public static var options: Self = "OPTIONS" 13 | } 14 | -------------------------------------------------------------------------------- /Source/HAGlobal.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// Global scoping of outward-facing dependencies used within the library 4 | public enum HAGlobal { 5 | /// The log level 6 | public enum LogLevel { 7 | /// A log representing things like state transitions and connectivity changes 8 | case info 9 | /// A log representing an error condition 10 | case error 11 | } 12 | 13 | /// Verbose logging from the library; defaults to not doing anything 14 | public static var log: (LogLevel, String) -> Void = { _, _ in } 15 | /// Used to mutate date handling for reconnect retrying 16 | public static var date: () -> Date = Date.init 17 | } 18 | -------------------------------------------------------------------------------- /Source/Internal/HAConnectionImpl+Requests.swift: -------------------------------------------------------------------------------- 1 | extension HAConnectionImpl: HARequestControllerDelegate { 2 | func requestControllerAllowedSendKinds( 3 | _ requestController: HARequestController 4 | ) -> HARequestControllerAllowedSendKind { 5 | switch responseController.phase { 6 | case .auth, .disconnected: return .rest 7 | case .command: return .all 8 | } 9 | } 10 | 11 | func requestController( 12 | _ requestController: HARequestController, 13 | didPrepareRequest request: HARequest, 14 | with identifier: HARequestIdentifier 15 | ) { 16 | sendRaw(identifier: identifier, request: request) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Tests/HACancellableImpl.test.swift: -------------------------------------------------------------------------------- 1 | @testable import HAKit 2 | import XCTest 3 | 4 | internal class HACancellableImplTests: XCTestCase { 5 | func testInvokingHandler() { 6 | let expectation = self.expectation(description: "invoked cancellable") 7 | expectation.assertForOverFulfill = true 8 | expectation.expectedFulfillmentCount = 1 9 | 10 | let cancellable = HACancellableImpl(handler: { 11 | expectation.fulfill() 12 | }) 13 | 14 | cancellable.cancel() 15 | 16 | // make sure invokoing it twice doesn't fire the handler twice 17 | cancellable.cancel() 18 | 19 | waitForExpectations(timeout: 10.0) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "object": { 3 | "pins": [ 4 | { 5 | "package": "PromiseKit", 6 | "repositoryURL": "https://github.com/mxcl/PromiseKit", 7 | "state": { 8 | "branch": null, 9 | "revision": "6fcc08077124e9747f1ec7bd8bb78f5caffe5a79", 10 | "version": "8.1.2" 11 | } 12 | }, 13 | { 14 | "package": "Starscream", 15 | "repositoryURL": "https://github.com/bgoncal/Starscream", 16 | "state": { 17 | "branch": null, 18 | "revision": "7e3d24425c20649105cb4bdd612b6bab66f73ade", 19 | "version": "4.0.8" 20 | } 21 | } 22 | ] 23 | }, 24 | "version": 1 25 | } 26 | -------------------------------------------------------------------------------- /Source/HAKit.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// Namespace entrypoint of the library 4 | public enum HAKit { 5 | /// Create a new connection 6 | /// - Parameter configuration: The configuration for the connection 7 | /// - Parameter connectAutomatically: Defaults to `true`. Whether to call .connect() automatically. 8 | /// - Returns: The connection itself 9 | public static func connection( 10 | configuration: HAConnectionConfiguration, 11 | connectAutomatically: Bool = true 12 | ) -> HAConnection { 13 | HAConnectionImpl( 14 | configuration: configuration, 15 | connectAutomatically: connectAutomatically 16 | ) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /.github/workflows/docs.yml: -------------------------------------------------------------------------------- 1 | name: docs 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: 7 | - main 8 | 9 | env: 10 | HOMEBREW_NO_INSTALL_CLEANUP: TRUE 11 | 12 | jobs: 13 | generate: 14 | runs-on: macos-14 15 | timeout-minutes: 10 16 | steps: 17 | - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 18 | - name: Install Jazzy 19 | run: gem install jazzy 20 | - name: Build Docs 21 | run: make docs 22 | - name: Deploy 23 | uses: peaceiris/actions-gh-pages@4f9cc6602d3f66b9c108549d475ec49e8ef4d45e # v4.0.0 24 | with: 25 | github_token: ${{ secrets.GITHUB_TOKEN }} 26 | publish_dir: ./docs 27 | -------------------------------------------------------------------------------- /Source/Internal/RequestController/HARequestInvocationSingle.swift: -------------------------------------------------------------------------------- 1 | internal class HARequestInvocationSingle: HARequestInvocation { 2 | private var completion: HAResetLock 3 | 4 | init( 5 | request: HARequest, 6 | completion: @escaping HAConnection.RequestCompletion 7 | ) { 8 | self.completion = .init(value: completion) 9 | super.init(request: request) 10 | } 11 | 12 | override func cancel() { 13 | super.cancel() 14 | completion.reset() 15 | } 16 | 17 | override var needsAssignment: Bool { 18 | super.needsAssignment && completion.read() != nil 19 | } 20 | 21 | func resolve(_ result: Result) { 22 | completion.pop()?(result) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Tests/HAKit.test.swift: -------------------------------------------------------------------------------- 1 | @testable import HAKit 2 | import XCTest 3 | 4 | internal class HAKitTests: XCTestCase { 5 | func testCreation() { 6 | let configuration = HAConnectionConfiguration.test 7 | let connection = HAKit.connection(configuration: configuration) 8 | XCTAssertEqual(connection.configuration.connectionInfo(), configuration.connectionInfo()) 9 | 10 | let expectation = self.expectation(description: "access token") 11 | connection.configuration.fetchAuthToken { connectionValue in 12 | configuration.fetchAuthToken { testValue in 13 | XCTAssertEqual(try? connectionValue.get(), try? testValue.get()) 14 | expectation.fulfill() 15 | } 16 | } 17 | 18 | waitForExpectations(timeout: 10.0) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Source/HAConnectionConfiguration.swift: -------------------------------------------------------------------------------- 1 | /// Configuration of the connection 2 | public struct HAConnectionConfiguration { 3 | /// Create a new configuration 4 | /// - Parameters: 5 | /// - connectionInfo: Block which provides the connection info on demand 6 | /// - fetchAuthToken: Block which invokes a closure asynchronously to provide authentication access tokens 7 | public init( 8 | connectionInfo: @escaping () -> HAConnectionInfo?, 9 | fetchAuthToken: @escaping (@escaping (Result) -> Void) -> Void 10 | ) { 11 | self.connectionInfo = connectionInfo 12 | self.fetchAuthToken = fetchAuthToken 13 | } 14 | 15 | /// The connection info provider block 16 | public var connectionInfo: () -> HAConnectionInfo? 17 | /// The auth token provider block 18 | public var fetchAuthToken: (_ completion: @escaping (Result) -> Void) -> Void 19 | } 20 | -------------------------------------------------------------------------------- /Source/Requests/HASttData.swift: -------------------------------------------------------------------------------- 1 | /// Write audio data to websocket, sttBinaryHandlerId is provided by run-start in Assist pipeline 2 | public struct HASttHandlerId: Hashable { 3 | var rawValue: UInt8 4 | 5 | public init(rawValue: UInt8) { 6 | self.rawValue = rawValue 7 | } 8 | } 9 | 10 | public extension HATypedRequest { 11 | /// Send binary stream STT data 12 | /// - Parameters: 13 | /// - sttHandlerId: Handler Id provided by run-start event from Assist pipeline 14 | /// - audioDataBase64Encoded: Audio data base 64 encoded 15 | /// - Returns: A typed request that can be sent via `HAConnection` 16 | static func sendSttData(sttHandlerId: UInt8, audioDataBase64Encoded: String) -> HATypedRequest { 17 | .init(request: .init(type: .sttData(.init(rawValue: sttHandlerId)), data: [ 18 | "audioData": audioDataBase64Encoded, 19 | ])) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Source/Internal/HAStarscreamCertificatePinningImpl.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Starscream 3 | 4 | internal class HAStarscreamCertificatePinningImpl: CertificatePinning { 5 | let evaluateCertificate: HAConnectionInfo.EvaluateCertificate 6 | init(evaluateCertificate: @escaping HAConnectionInfo.EvaluateCertificate) { 7 | self.evaluateCertificate = evaluateCertificate 8 | } 9 | 10 | func evaluateTrust(trust: SecTrust, domain: String?, completion: (PinningState) -> Void) { 11 | evaluateCertificate(trust, { 12 | switch $0 { 13 | case .success: 14 | completion(.success) 15 | case let .failure(error): 16 | // although it looks like it would always succeed, a Swift Error may not be convertable here 17 | completion(.failed(error as? CFError? ?? CFErrorCreate(nil, "UnknownSSL" as CFErrorDomain, 1, nil))) 18 | } 19 | }) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | test: 2 | swift test --enable-code-coverage --sanitize=thread --enable-test-discovery 3 | generate-project: 4 | # optionally makes an xcodeproj (instead of opening the package directly) 5 | swift package generate-xcodeproj --enable-code-coverage 6 | open: 7 | open Package.swift 8 | docs: 9 | jazzy 10 | 11 | .PHONY: docs 12 | 13 | swiftlint: 14 | ifeq (${CI}, true) 15 | swiftlint --config .swiftlint.yml --quiet --strict --reporter github-actions-logging . 16 | else 17 | @echo LINT: SwiftLint... 18 | @swiftlint --config .swiftlint.yml --quiet . 19 | endif 20 | swiftformat: 21 | ifeq (${CI}, true) 22 | swiftformat --config .swiftformat --lint . 23 | else 24 | @echo LINT: SwiftFormat... 25 | @swiftformat --config .swiftformat --quiet . 26 | endif 27 | drstring: 28 | ifeq (${CI}, true) 29 | drstring check --config-file .drstring.toml 30 | else 31 | @echo LINT: DrString... 32 | @drstring format --config-file .drstring.toml || true 33 | @drstring check --config-file .drstring.toml || true 34 | endif 35 | lint: swiftlint swiftformat drstring 36 | -------------------------------------------------------------------------------- /Source/Data/HAEntity+CompressedEntity.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension HAEntity { 4 | mutating func update(from state: HACompressedEntityState) { 5 | self.state = state.state 6 | lastChanged = state.lastChanged ?? lastChanged 7 | lastUpdated = state.lastUpdated ?? lastUpdated 8 | attributes.dictionary = state.attributes ?? attributes.dictionary 9 | context = .init(id: state.context ?? "", userId: nil, parentId: nil) 10 | } 11 | 12 | mutating func add(_ state: HACompressedEntityState) { 13 | self.state = state.state 14 | lastChanged = state.lastChanged ?? lastChanged 15 | lastUpdated = state.lastUpdated ?? lastUpdated 16 | attributes.dictionary.merge(state.attributes ?? [:]) { current, _ in current } 17 | context = .init(id: state.context ?? "", userId: nil, parentId: nil) 18 | } 19 | 20 | mutating func subtract(_ state: HACompressedEntityStateRemove) { 21 | attributes.dictionary = attributes.dictionary.filter { !(state.attributes?.contains($0.key) ?? false) } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /Source/Caches/HACacheTransformInfo.swift: -------------------------------------------------------------------------------- 1 | /// Information about a state change which needs transform 2 | public struct HACacheTransformInfo { 3 | /// The value coming into this state change 4 | /// For populate transforms, this is the request's response 5 | /// For subscribe transforms, this is the subscription's event value 6 | public var incoming: IncomingType 7 | 8 | /// The current value of the cache 9 | /// For populate transforms, this is nil if an initial request hasn't been sent yet and the cache not reset. 10 | /// For subscribe transforms, this is nil if the populate did not produce results (or does not exist). 11 | public var current: OutgoingType 12 | 13 | /// The current phase of the subscription 14 | public var subscriptionPhase: HACacheSubscriptionPhase = .initial 15 | } 16 | 17 | /// The subscription phases 18 | public enum HACacheSubscriptionPhase { 19 | /// `Initial` means it's the first time a value is returned 20 | case initial 21 | /// `Iteration` means subsequent iterations 22 | case iteration 23 | } 24 | -------------------------------------------------------------------------------- /Tests/TestAdditions.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import HAKit 3 | 4 | internal extension Array { 5 | enum GetError: Error { 6 | case outOfRange(offset: Int, count: Int) 7 | } 8 | 9 | func get(throwing offset: Int) throws -> Element { 10 | if count > offset { 11 | return self[offset] 12 | } else { 13 | throw GetError.outOfRange(offset: offset, count: count) 14 | } 15 | } 16 | } 17 | 18 | internal extension HAData { 19 | init(testJsonString jsonString: String) { 20 | self.init(value: try? JSONSerialization.jsonObject( 21 | with: jsonString.data(using: .utf8)!, 22 | options: .allowFragments 23 | )) 24 | } 25 | } 26 | 27 | internal extension HAConnectionConfiguration { 28 | static var test: Self { 29 | let url = URL(string: "https://example.com")! 30 | let accessToken: String = UUID().uuidString 31 | 32 | return .init( 33 | connectionInfo: { try? .init(url: url) }, 34 | fetchAuthToken: { $0(.success(accessToken)) } 35 | ) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /Tests/FakeEngine.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Starscream 3 | 4 | internal class FakeEngine: Engine { 5 | weak var delegate: EngineDelegate? 6 | var events = [Event]() 7 | 8 | func register(delegate: EngineDelegate) { 9 | self.delegate = delegate 10 | } 11 | 12 | enum Event: Equatable { 13 | case start(URLRequest) 14 | case stop(UInt16) 15 | case forceStop 16 | case writeString(String) 17 | case writeData(Data, opcode: FrameOpCode) 18 | } 19 | 20 | func start(request: URLRequest) { 21 | events.append(.start(request)) 22 | } 23 | 24 | func stop(closeCode: UInt16) { 25 | events.append(.stop(closeCode)) 26 | } 27 | 28 | func forceStop() { 29 | events.append(.forceStop) 30 | } 31 | 32 | func write(data: Data, opcode: FrameOpCode, completion: (() -> Void)?) { 33 | events.append(.writeData(data, opcode: opcode)) 34 | completion?() 35 | } 36 | 37 | func write(string: String, completion: (() -> Void)?) { 38 | events.append(.writeString(string)) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /Tests/HACachedUser.test.swift: -------------------------------------------------------------------------------- 1 | import HAKit 2 | import XCTest 3 | #if SWIFT_PACKAGE 4 | import HAKit_Mocks 5 | #endif 6 | 7 | internal class HACachedUserTests: XCTestCase { 8 | private var connection: HAMockConnection! 9 | private var container: HACachesContainer! 10 | 11 | override func setUp() { 12 | super.setUp() 13 | connection = HAMockConnection() 14 | container = HACachesContainer(connection: connection) 15 | } 16 | 17 | func testKeyAccess() { 18 | _ = container.user 19 | } 20 | 21 | func testRequest() throws { 22 | let cache = container.user 23 | let populate = try XCTUnwrap(cache.populateInfo) 24 | XCTAssertTrue(cache.subscribeInfo?.isEmpty ?? false) 25 | 26 | XCTAssertEqual(populate.request.type, .currentUser) 27 | XCTAssertTrue(populate.request.data.isEmpty) 28 | 29 | let user = HAResponseCurrentUser( 30 | id: "id", 31 | name: "name", 32 | isOwner: false, 33 | isAdmin: false, 34 | credentials: [], 35 | mfaModules: [] 36 | ) 37 | let result = try populate.transform(incoming: user, current: nil) 38 | XCTAssertEqual(result.id, user.id) 39 | XCTAssertEqual(result.name, user.name) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /.swiftlint.yml: -------------------------------------------------------------------------------- 1 | only_rules: 2 | - class_delegate_protocol 3 | - compiler_protocol_init 4 | - contains_over_filter_count 5 | - cyclomatic_complexity 6 | - discarded_notification_center_observer 7 | - discouraged_direct_init 8 | - duplicate_enum_cases 9 | - explicit_top_level_acl 10 | - fallthrough 11 | - for_where 12 | - force_cast 13 | - force_try 14 | - generic_type_name 15 | - identical_operands 16 | - inclusive_language 17 | - is_disjoint 18 | - legacy_cggeometry_functions 19 | - legacy_constant 20 | - legacy_constructor 21 | - legacy_hashing 22 | - legacy_multiple 23 | - legacy_nsgeometry_functions 24 | - legacy_random 25 | - missing_docs 26 | - nsobject_prefer_isequal 27 | - override_in_extension 28 | - prefer_self_type_over_type_of_self 29 | - private_unit_test 30 | - prohibited_super_call 31 | - reduce_boolean 32 | - redundant_objc_attribute 33 | - static_operator 34 | - superfluous_disable_command 35 | - test_case_accessibility 36 | - unneeded_break_in_switch 37 | - unowned_variable_capture 38 | - unused_control_flow_label 39 | - unused_declaration 40 | - unused_enumerated 41 | - unused_optional_binding 42 | - unused_setter_value 43 | - weak_delegate 44 | - xctfail_message 45 | 46 | cyclomatic_complexity: 47 | ignores_case_statements: true 48 | 49 | excluded: 50 | - Example 51 | - .build 52 | -------------------------------------------------------------------------------- /Tests/HADataDecodable.test.swift: -------------------------------------------------------------------------------- 1 | import HAKit 2 | import XCTest 3 | 4 | internal class HADataDecodableTests: XCTestCase { 5 | override func setUp() { 6 | super.setUp() 7 | RandomDecodable.shouldThrow = false 8 | } 9 | 10 | func testArrayDecodableSuccess() throws { 11 | let data = HAData.array([.empty, .empty, .empty]) 12 | let result = try [RandomDecodable](data: data) 13 | XCTAssertEqual(result.count, 3) 14 | } 15 | 16 | func testArrayDecodableFailureDueToRootData() { 17 | let data = HAData.dictionary(["key": "value"]) 18 | XCTAssertThrowsError(try [RandomDecodable](data: data)) { error in 19 | XCTAssertEqual(error as? HADataError, .couldntTransform(key: "root")) 20 | } 21 | } 22 | 23 | func testArrayDecodableFailureDueToInside() throws { 24 | RandomDecodable.shouldThrow = true 25 | let data = HAData.array([.empty, .empty, .empty]) 26 | XCTAssertThrowsError(try [RandomDecodable](data: data)) { error in 27 | XCTAssertEqual(error as? HADataError, .missingKey("any")) 28 | } 29 | } 30 | } 31 | 32 | private class RandomDecodable: HADataDecodable { 33 | static var shouldThrow = false 34 | required init(data: HAData) throws { 35 | if Self.shouldThrow { 36 | throw HADataError.missingKey("any") 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Tests/HAError.test.swift: -------------------------------------------------------------------------------- 1 | @testable import HAKit 2 | import XCTest 3 | 4 | internal class HAErrorTests: XCTestCase { 5 | func testLocalizedDescription() { 6 | let error1 = HAError.internal(debugDescription: "msg1") 7 | XCTAssertEqual(error1.localizedDescription, "msg1") 8 | 9 | let error2 = HAError.external(.init(code: "code", message: "msg2")) 10 | XCTAssertEqual(error2.localizedDescription, "msg2") 11 | 12 | let underlying = NSError(domain: "test", code: 123, userInfo: [:]) 13 | let error3 = HAError.underlying(underlying) 14 | XCTAssertEqual(error3.localizedDescription, underlying.localizedDescription) 15 | } 16 | 17 | func testExternalErrorInitWithBad() { 18 | XCTAssertEqual(HAError.ExternalError(true), .invalid) 19 | XCTAssertEqual(HAError.ExternalError([:]), .invalid) 20 | XCTAssertEqual(HAError.ExternalError(["code": nil, "message": "message"]), .invalid) 21 | } 22 | 23 | func testExternalErrorInitWithGood() { 24 | let error1 = HAError.ExternalError(["code": "code1", "message": "msg"]) 25 | XCTAssertEqual(error1.code, "code1") 26 | XCTAssertEqual(error1.message, "msg") 27 | 28 | let error2 = HAError.ExternalError(["code": "code2", "message": "msg2"]) 29 | XCTAssertEqual(error2.code, "code2") 30 | XCTAssertEqual(error2.message, "msg2") 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # jazzy output 2 | build/ 3 | docs/ 4 | 5 | # Local Netlify folder 6 | .netlify 7 | 8 | # Created by https://www.toptal.com/developers/gitignore/api/macos,swiftpm,cocoapods,homebrew 9 | # Edit at https://www.toptal.com/developers/gitignore?templates=macos,swiftpm,cocoapods,homebrew 10 | 11 | ### CocoaPods ### 12 | ## CocoaPods GitIgnore Template 13 | 14 | # CocoaPods - Only use to conserve bandwidth / Save time on Pushing 15 | # - Also handy if you have a large number of dependant pods 16 | # - AS PER https://guides.cocoapods.org/using/using-cocoapods.html NEVER IGNORE THE LOCK FILE 17 | Pods/ 18 | 19 | ### Homebrew ### 20 | Brewfile.lock.json 21 | 22 | ### macOS ### 23 | # General 24 | .DS_Store 25 | .AppleDouble 26 | .LSOverride 27 | 28 | # Icon must end with two \r 29 | Icon 30 | 31 | 32 | # Thumbnails 33 | ._* 34 | 35 | # Files that might appear in the root of a volume 36 | .DocumentRevisions-V100 37 | .fseventsd 38 | .Spotlight-V100 39 | .TemporaryItems 40 | .Trashes 41 | .VolumeIcon.icns 42 | .com.apple.timemachine.donotpresent 43 | 44 | # Directories potentially created on remote AFP share 45 | .AppleDB 46 | .AppleDesktop 47 | Network Trash Folder 48 | Temporary Items 49 | .apdisk 50 | 51 | ### SwiftPM ### 52 | Packages 53 | .build/ 54 | xcuserdata 55 | DerivedData/ 56 | *.xcodeproj 57 | 58 | 59 | # End of https://www.toptal.com/developers/gitignore/api/macos,swiftpm,cocoapods,homebrew 60 | -------------------------------------------------------------------------------- /Source/Internal/RequestController/HARequestInvocation.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | internal class HARequestInvocation: Equatable, Hashable { 4 | private let uniqueID = UUID() 5 | let request: HARequest 6 | var identifier: HARequestIdentifier? 7 | let createdAt: Date 8 | 9 | init(request: HARequest) { 10 | self.request = request 11 | self.createdAt = HAGlobal.date() 12 | } 13 | 14 | static func == (lhs: HARequestInvocation, rhs: HARequestInvocation) -> Bool { 15 | lhs.uniqueID == rhs.uniqueID 16 | } 17 | 18 | func hash(into hasher: inout Hasher) { 19 | hasher.combine(uniqueID) 20 | } 21 | 22 | var needsAssignment: Bool { 23 | // for subclasses, too 24 | identifier == nil 25 | } 26 | 27 | /// Check if the request's retry duration has expired 28 | var isRetryTimeoutExpired: Bool { 29 | guard let duration = request.retryDuration else { 30 | return false // No duration means never expires 31 | } 32 | let elapsed = HAGlobal.date().timeIntervalSince(createdAt) 33 | let durationInSeconds = duration.converted(to: .seconds).value 34 | return elapsed > durationInSeconds 35 | } 36 | 37 | func cancelRequest() -> HATypedRequest? { 38 | // most requests do not need another request to be sent to be cancelled 39 | nil 40 | } 41 | 42 | func cancel() { 43 | // for subclasses 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /HAKit.podspec: -------------------------------------------------------------------------------- 1 | Pod::Spec.new do |s| 2 | s.name = 'HAKit' 3 | s.version = '0.4.8' 4 | s.summary = 'Communicate with a Home Assistant instance.' 5 | s.author = 'Home Assistant' 6 | 7 | s.homepage = 'https://github.com/home-assistant/HAKit' 8 | s.license = { type: 'Apache 2', file: 'LICENSE.md' } 9 | s.source = { git: 'https://github.com/home-assistant/HAKit.git', tag: s.version.to_s } 10 | 11 | s.ios.deployment_target = '12.0' 12 | s.tvos.deployment_target = '12.0' 13 | s.watchos.deployment_target = '5.0' 14 | s.macos.deployment_target = '10.14' 15 | 16 | s.swift_versions = ['5.3'] 17 | 18 | s.default_subspec = 'Core' 19 | 20 | s.subspec 'Core' do |subspec| 21 | subspec.source_files = 'Source/**/*.swift' 22 | subspec.dependency 'Starscream', '~> 4.0.4' 23 | end 24 | 25 | s.subspec 'PromiseKit' do |subspec| 26 | subspec.dependency 'PromiseKit', '~> 8.1.1' 27 | subspec.dependency 'HAKit/Core' 28 | subspec.source_files = 'Extensions/PromiseKit/**/*.swift' 29 | end 30 | 31 | s.subspec 'Mocks' do |subspec| 32 | subspec.dependency 'HAKit/Core' 33 | subspec.source_files = 'Extensions/Mocks/**/*.swift' 34 | end 35 | 36 | s.test_spec 'Tests' do |test_spec| 37 | test_spec.platform = :ios, '12.0' 38 | test_spec.dependency 'HAKit/Core' 39 | test_spec.dependency 'HAKit/PromiseKit' 40 | test_spec.dependency 'HAKit/Mocks' 41 | test_spec.macos.deployment_target = '10.14' 42 | test_spec.source_files = 'Tests/*.swift' 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /Source/Internal/HAProtected.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// Wrapper around a value with a lock 4 | /// 5 | /// Provided publicly as a convenience in case the library uses it anywhere. 6 | public class HAProtected { 7 | private var value: ValueType 8 | private let lock: os_unfair_lock_t = { 9 | let value = os_unfair_lock_t.allocate(capacity: 1) 10 | value.initialize(to: os_unfair_lock()) 11 | return value 12 | }() 13 | 14 | /// Create a new protected value 15 | /// - Parameter value: The initial value 16 | public init(value: ValueType) { 17 | self.value = value 18 | } 19 | 20 | deinit { 21 | lock.deinitialize(count: 1) 22 | lock.deallocate() 23 | } 24 | 25 | /// Get and optionally change the value 26 | /// - Parameter handler: Will be invoked immediately with the current value as an inout parameter. 27 | /// - Returns: The value returned by the handler block 28 | @discardableResult 29 | public func mutate(using handler: (inout ValueType) -> HandlerType) -> HandlerType { 30 | os_unfair_lock_lock(lock) 31 | defer { os_unfair_lock_unlock(lock) } 32 | return handler(&value) 33 | } 34 | 35 | /// Read the value and get a result out of it 36 | /// - Parameter handler: Will be invoked immediately with the current value. 37 | /// - Returns: The value returned by the handler block 38 | public func read(_ handler: (ValueType) -> T) -> T { 39 | os_unfair_lock_lock(lock) 40 | defer { os_unfair_lock_unlock(lock) } 41 | return handler(value) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | - Read [How to get faster PR reviews](https://github.com/kubernetes/community/blob/master/contributors/guide/pull-requests.md#best-practices-for-faster-reviews) by Kubernetes (but skip step 0) 4 | - Create a Pull Request against the **main** branch. 5 | 6 | ## Guidelines 7 | 8 | - Convenience methods, request types and event names can be added for any request or event in the Home Assistant core repository. Calls which are added via third-party components should not be added here. 9 | - Code coverage must be maintained for all changes. Make sure your tests execute successfully. 10 | 11 | ## Building the library 12 | - `brew bundle` installs linting and other utilities. 13 | - `make open` launches the Swift Project Manager version of the library in Xcode. 14 | - `make generate-project` creates an openable `.xcodeproj` from the Swift package. 15 | - `make test` executes all tests for the library. 16 | - `make lint` execute linting. Locally, this will also apply autocorrects. 17 | 18 | ## Learn Swift 19 | This project is a good opportunity to learn Swift programming and contribute back to a friendly and welcoming Open Source community. We've collected some pointers to get you started. 20 | 21 | * Apple's [The Swift Programming Language](https://www.apple.com/swift/) is a great resource to start learning Swift programming. It also happens to be free. 22 | * There are also some great tutorials and boot camps available: 23 | * [Big Nerd Ranch](https://www.bignerdranch.com/) 24 | * [objc.io](https://www.objc.io) 25 | * [Point-Free](https://www.pointfree.co) 26 | * [raywenderlich.com](https://www.raywenderlich.com/) 27 | -------------------------------------------------------------------------------- /Source/Caches/HACachedStates.swift: -------------------------------------------------------------------------------- 1 | public extension HACachesContainer { 2 | /// Cache of entity states, see `HACachedStates` for values. 3 | /// - Parameter data: The data passed to connection request 4 | /// - Returns: The cache object with states 5 | func states(_ data: [String: Any] = [:]) -> HACache { 6 | self[HACacheKeyStates.self, data] 7 | } 8 | } 9 | 10 | /// Cached version of all entity states 11 | public struct HACachedStates { 12 | /// All entities 13 | public var all: Set = [] 14 | /// All entities, keyed by their entityId 15 | public subscript(entityID: String) -> HAEntity? { 16 | get { allByEntityId[entityID] } 17 | set { allByEntityId[entityID] = newValue } 18 | } 19 | 20 | /// Backing dictionary, whose mutation updates the set 21 | internal var allByEntityId: [String: HAEntity] { 22 | didSet { 23 | all = Set(allByEntityId.values) 24 | } 25 | } 26 | 27 | /// Create a cached state 28 | /// - Parameter entitiesDictionary: The entities to start with, key is the entity ID 29 | public init(entitiesDictionary: [String: HAEntity]) { 30 | self.all = Set(entitiesDictionary.values) 31 | self.allByEntityId = entitiesDictionary 32 | } 33 | 34 | /// Create a cached state 35 | /// Mainly for tests 36 | /// - Parameter entities: The entities to start with 37 | internal init(entities: [HAEntity]) { 38 | self.all = Set(entities) 39 | self.allByEntityId = entities.reduce(into: [:]) { dictionary, entity in 40 | dictionary[entity.entityId] = entity 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /Tests/StubbingURLProtocol.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import XCTest 3 | 4 | internal class StubbingURLProtocol: URLProtocol { 5 | typealias PendingResult = Result<(HTTPURLResponse, Data?), Error> 6 | private static var pending = [URL: PendingResult]() 7 | static var received = [URL: URLRequest]() 8 | 9 | class func register( 10 | _ url: URL, 11 | result: PendingResult 12 | ) { 13 | pending[url] = result 14 | } 15 | 16 | override class func canInit(with request: URLRequest) -> Bool { 17 | request.url != nil 18 | } 19 | 20 | override class func canonicalRequest(for request: URLRequest) -> URLRequest { 21 | // default implementation asserts 22 | request 23 | } 24 | 25 | override func startLoading() { 26 | guard let url = request.url, let result = Self.pending.removeValue(forKey: url) else { 27 | XCTFail("unexpected request: \(request)") 28 | return 29 | } 30 | 31 | guard let client = client else { 32 | XCTFail("unable to complete") 33 | return 34 | } 35 | 36 | Self.received[url] = request 37 | 38 | switch result { 39 | case let .success((response, data)): 40 | client.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) 41 | if let data = data { 42 | client.urlProtocol(self, didLoad: data) 43 | } 44 | client.urlProtocolDidFinishLoading(self) 45 | case let .failure(error): 46 | client.urlProtocol(self, didFailWithError: error) 47 | } 48 | } 49 | 50 | override func stopLoading() {} 51 | } 52 | -------------------------------------------------------------------------------- /Extensions/PromiseKit/HAConnection+PromiseKit.swift: -------------------------------------------------------------------------------- 1 | #if SWIFT_PACKAGE 2 | import HAKit 3 | #endif 4 | import PromiseKit 5 | 6 | public extension HAConnection { 7 | /// Send a request 8 | /// 9 | /// Wraps a normal request send in a Promise. 10 | /// 11 | /// - SeeAlso: `HAConnection.send(_:completion:)` 12 | /// - Parameter request: The request to send 13 | /// - Returns: The promies for the request, and a block to cancel 14 | func send(_ request: HARequest) -> (promise: Promise, cancel: () -> Void) { 15 | let (promise, seal) = Promise.pending() 16 | let token = send(request, completion: { result in 17 | switch result { 18 | case let .success(data): seal.fulfill(data) 19 | case let .failure(error): seal.reject(error) 20 | } 21 | }) 22 | return (promise: promise, cancel: token.cancel) 23 | } 24 | 25 | /// Send a request with a concrete response type 26 | /// 27 | /// Wraps a typed request send in a Promise. 28 | /// 29 | /// - SeeAlso: `HAConnection.send(_:completion:)` 30 | /// - Parameter request: The request to send 31 | /// - Returns: The promise for the request, and a block to cancel 32 | func send(_ request: HATypedRequest) -> (promise: Promise, cancel: () -> Void) { 33 | let (promise, seal) = Promise.pending() 34 | let token = send(request, completion: { result in 35 | switch result { 36 | case let .success(data): seal.fulfill(data) 37 | case let .failure(error): seal.reject(error) 38 | } 39 | }) 40 | return (promise: promise, cancel: token.cancel) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /Source/Internal/RequestController/HARequestInvocationSubscription.swift: -------------------------------------------------------------------------------- 1 | internal class HARequestInvocationSubscription: HARequestInvocation { 2 | private var handler: HAResetLock 3 | private var initiated: HAResetLock 4 | private var lastInitiatedResult: Result? 5 | 6 | init( 7 | request: HARequest, 8 | initiated: HAConnection.SubscriptionInitiatedHandler?, 9 | handler: @escaping HAConnection.SubscriptionHandler 10 | ) { 11 | self.initiated = .init(value: initiated) 12 | self.handler = .init(value: handler) 13 | super.init(request: request) 14 | } 15 | 16 | override func cancel() { 17 | super.cancel() 18 | handler.reset() 19 | initiated.reset() 20 | } 21 | 22 | internal var needsRetry: Bool { 23 | if case .failure = lastInitiatedResult { 24 | return true 25 | } else { 26 | return false 27 | } 28 | } 29 | 30 | override var needsAssignment: Bool { 31 | // not initiated, since it is optional 32 | super.needsAssignment && handler.read() != nil 33 | } 34 | 35 | override func cancelRequest() -> HATypedRequest? { 36 | guard let identifier = identifier else { 37 | return nil 38 | } 39 | 40 | return .unsubscribe(identifier) 41 | } 42 | 43 | func resolve(_ result: Result) { 44 | lastInitiatedResult = result 45 | initiated.read()?(result) 46 | } 47 | 48 | func invoke(token: HACancellableImpl, event: HAData) { 49 | handler.read()?(token, event) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.3 2 | 3 | import PackageDescription 4 | 5 | /// The Package 6 | public let package = Package( 7 | name: "HAKit", 8 | platforms: [ 9 | .iOS(.v12), 10 | .macOS(.v10_14), 11 | .tvOS(.v12), 12 | .watchOS(.v5), 13 | ], 14 | products: [ 15 | .library( 16 | name: "HAKit", 17 | targets: ["HAKit"] 18 | ), 19 | ], 20 | dependencies: [ 21 | .package( 22 | url: "https://github.com/bgoncal/Starscream", 23 | from: "4.0.8" 24 | ), 25 | .package( 26 | url: "https://github.com/mxcl/PromiseKit", 27 | from: "8.1.1" 28 | ), 29 | ], 30 | targets: [ 31 | .target( 32 | name: "HAKit", 33 | dependencies: [ 34 | .byName(name: "Starscream"), 35 | ], 36 | path: "Source" 37 | ), 38 | .target( 39 | name: "HAKit+PromiseKit", 40 | dependencies: [ 41 | .byName(name: "HAKit"), 42 | .byName(name: "PromiseKit"), 43 | ], 44 | path: "Extensions/PromiseKit" 45 | ), 46 | .target( 47 | name: "HAKit+Mocks", 48 | dependencies: [ 49 | .byName(name: "HAKit"), 50 | ], 51 | path: "Extensions/Mocks" 52 | ), 53 | .testTarget( 54 | name: "Tests", 55 | dependencies: [ 56 | .byName(name: "HAKit"), 57 | .byName(name: "HAKit+PromiseKit"), 58 | .byName(name: "HAKit+Mocks"), 59 | ], 60 | path: "Tests" 61 | ), 62 | ] 63 | ) 64 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - main 8 | 9 | env: 10 | DEVELOPER_DIR: /Applications/Xcode.app/Contents/Developer 11 | HOMEBREW_NO_INSTALL_CLEANUP: TRUE 12 | 13 | jobs: 14 | lint: 15 | runs-on: macos-15 16 | steps: 17 | - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 18 | - name: Install Dependencies 19 | run: brew bundle 20 | - name: "Run Linting" 21 | run: make lint 22 | 23 | test: 24 | runs-on: macos-15 25 | timeout-minutes: 10 26 | steps: 27 | - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 28 | - name: Install Dependencies 29 | run: brew bundle 30 | - name: Run Tests 31 | run: make test 32 | - name: Generate lcov 33 | # https://www.reddit.com/r/swift/comments/hr93xg/code_coverage_for_swiftpm_packages/ 34 | run: | 35 | BIN_PATH="$(swift build --show-bin-path)" 36 | XCTEST_PATH="$(find ${BIN_PATH} -name '*.xctest')" 37 | COV_BIN=$XCTEST_PATH 38 | if [[ "$OSTYPE" == "darwin"* ]]; then 39 | f="$(basename $XCTEST_PATH .xctest)" 40 | COV_BIN="${COV_BIN}/Contents/MacOS/$f" 41 | fi 42 | xcrun llvm-cov export -format="lcov" \ 43 | "${COV_BIN}" \ 44 | -instr-profile=.build/debug/codecov/default.profdata \ 45 | -ignore-filename-regex=".build|Tests"\ > info.lcov 46 | - uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2 47 | name: Upload Code Coverage 48 | with: 49 | file: ./info.lcov 50 | token: ${{ secrets.CODECOV_TOKEN }} 51 | 52 | -------------------------------------------------------------------------------- /Source/Data/HAService.swift: -------------------------------------------------------------------------------- 1 | /// The domain of a service 2 | /// 3 | /// For example, `light` in `light.turn_on` is the domain. 4 | public struct HAServicesDomain: RawRepresentable, Hashable, ExpressibleByStringLiteral { 5 | /// The domain as a string 6 | public var rawValue: String 7 | /// Construct a service domain from a raw value 8 | /// - Parameter rawValue: The raw value 9 | public init(rawValue: String) { self.rawValue = rawValue } 10 | /// Construct a service domain from a literal 11 | /// 12 | /// This is mainly useful when stringly-calling a parameter that takes this type. 13 | /// You should use the `HAServicesDomain.init(rawValue:)` initializer instead. 14 | /// 15 | /// - Parameter value: The literal value 16 | public init(stringLiteral value: StringLiteralType) { self.rawValue = value } 17 | } 18 | 19 | /// The service itself in a service call 20 | /// 21 | /// Many services can be used on multiple domains, but you should consult the full results to see which are available. 22 | /// 23 | /// For example, `turn_on` in `light.turn_on` is the service. 24 | public struct HAServicesService: RawRepresentable, Hashable, ExpressibleByStringLiteral { 25 | /// The service as a string 26 | public var rawValue: String 27 | /// Construct a service from a raw value 28 | /// - Parameter rawValue: The raw value 29 | public init(rawValue: String) { self.rawValue = rawValue } 30 | /// Construct a service from a literal 31 | /// 32 | /// This is mainly useful when stringly-calling a parameter that takes this type. 33 | /// You should use the `HAServicesService.init(rawValue:)` initializer instead. 34 | /// 35 | /// - Parameter value: The literal value 36 | public init(stringLiteral value: StringLiteralType) { self.rawValue = value } 37 | } 38 | -------------------------------------------------------------------------------- /CLA.md: -------------------------------------------------------------------------------- 1 | # Contributor License Agreement 2 | 3 | ``` 4 | By making a contribution to this project, I certify that: 5 | 6 | (a) The contribution was created in whole or in part by me and I 7 | have the right to submit it under the Apache 2.0 license; or 8 | 9 | (b) The contribution is based upon previous work that, to the best 10 | of my knowledge, is covered under an appropriate open source 11 | license and I have the right under that license to submit that 12 | work with modifications, whether created in whole or in part 13 | by me, under the Apache 2.0 license; or 14 | 15 | (c) The contribution was provided directly to me by some other 16 | person who certified (a), (b) or (c) and I have not modified 17 | it. 18 | 19 | (d) I understand and agree that this project and the contribution 20 | are public and that a record of the contribution (including all 21 | personal information I submit with it) is maintained indefinitely 22 | and may be redistributed consistent with this project or the open 23 | source license(s) involved. 24 | ``` 25 | 26 | ## Attribution 27 | 28 | The text of this license is available under the [Creative Commons Attribution-ShareAlike 3.0 Unported License](http://creativecommons.org/licenses/by-sa/3.0/). It is based on the Linux [Developer Certificate Of Origin](http://elinux.org/Developer_Certificate_Of_Origin), but is modified to explicitly use the Apache 2.0 license 29 | and not mention sign-off. 30 | 31 | ## Signing 32 | 33 | To sign this CLA you must first submit a pull request to a repository under the Home Assistant organization. 34 | 35 | ## Adoption 36 | 37 | This Contributor License Agreement (CLA) was first announced on January 21st, 2017 in [this][cla-blog] blog post and adopted January 28th, 2017. 38 | 39 | [cla-blog]: https://home-assistant.io/blog/2017/01/21/home-assistant-governance/ 40 | -------------------------------------------------------------------------------- /Source/Data/HACompressedEntity.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public struct HACompressedStatesUpdates: HADataDecodable { 4 | public var add: [String: HACompressedEntityState]? 5 | public var remove: [String]? 6 | public var change: [String: HACompressedEntityDiff]? 7 | 8 | public init(data: HAData) throws { 9 | self.add = try? data.decode("a") 10 | self.remove = try? data.decode("r") 11 | self.change = try? data.decode("c") 12 | } 13 | } 14 | 15 | public struct HACompressedEntityState: HADataDecodable { 16 | public var state: String 17 | public var attributes: [String: Any]? 18 | public var context: String? 19 | public var lastChanged: Date? 20 | public var lastUpdated: Date? 21 | 22 | public init(data: HAData) throws { 23 | self.state = try data.decode("s") 24 | self.attributes = try? data.decode("a") 25 | self.context = try? data.decode("c") 26 | self.lastChanged = try? data.decode("lc") 27 | self.lastUpdated = try? data.decode("lu") 28 | } 29 | 30 | func asEntity(entityId: String) throws -> HAEntity { 31 | try HAEntity( 32 | entityId: entityId, 33 | state: state, 34 | lastChanged: lastChanged ?? Date(), 35 | lastUpdated: lastUpdated ?? Date(), 36 | attributes: attributes ?? [:], 37 | context: .init(id: context ?? "", userId: nil, parentId: nil) 38 | ) 39 | } 40 | } 41 | 42 | public struct HACompressedEntityStateRemove: HADataDecodable { 43 | public var attributes: [String]? 44 | 45 | public init(data: HAData) throws { 46 | self.attributes = try? data.decode("a") 47 | } 48 | } 49 | 50 | public struct HACompressedEntityDiff: HADataDecodable { 51 | public var additions: HACompressedEntityState? 52 | public var subtractions: HACompressedEntityStateRemove? 53 | 54 | public init(data: HAData) throws { 55 | self.additions = try? data.decode("+") 56 | self.subtractions = try? data.decode("-") 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /Tests/HAResetLock.test.swift: -------------------------------------------------------------------------------- 1 | @testable import HAKit 2 | import XCTest 3 | 4 | internal class HAResetLockTests: XCTestCase { 5 | func testReset() { 6 | iteration { 7 | let (lock, callCount) = build { idx, lock in 8 | if idx.isMultiple(of: 2) { 9 | lock.read()?() 10 | } else { 11 | lock.reset() 12 | } 13 | } 14 | 15 | // this is mostly just testing that it doesn't crash, and doesn't give very large numbers 16 | XCTAssertLessThan(callCount, 10) 17 | XCTAssertNil(lock.read()) 18 | XCTAssertNil(lock.pop()) 19 | } 20 | } 21 | 22 | func testRead() { 23 | iteration { 24 | let (lock, callCount) = build { _, lock in 25 | lock.read()?() 26 | } 27 | 28 | XCTAssertEqual(callCount, 100) 29 | XCTAssertNotNil(lock.read()) 30 | XCTAssertNotNil(lock.pop()) 31 | } 32 | } 33 | 34 | func testPop() { 35 | iteration { 36 | let (lock, callCount) = build { _, lock in 37 | lock.pop()?() 38 | } 39 | 40 | XCTAssertEqual(callCount, 1) 41 | XCTAssertNil(lock.pop()) 42 | XCTAssertNil(lock.read()) 43 | } 44 | } 45 | 46 | private typealias LockValue = () -> Void 47 | 48 | private func iteration(_ block: () -> Void) { 49 | for _ in stride(from: 0, to: 100, by: 1) { 50 | block() 51 | } 52 | } 53 | 54 | private func build( 55 | with block: (Int, HAResetLock) -> Void 56 | ) -> (lock: HAResetLock, callCount: Int32) { 57 | var callCount: Int32 = 0 58 | 59 | let lock = HAResetLock<() -> Void>(value: { 60 | OSAtomicIncrement32(&callCount) 61 | }) 62 | 63 | DispatchQueue.concurrentPerform(iterations: 100, execute: { 64 | block($0, lock) 65 | }) 66 | 67 | return (lock, callCount) 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /Source/Requests/HARequest.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// A request, with data, to be issued 4 | public struct HARequest { 5 | /// Create a request 6 | /// - Precondition: data is a JSON-encodable value. From `JSONSerialization` documentation: 7 | /// * All objects are `String`, numbers (`Int`, `Float`, etc.), `Array`, `Dictionary`, or `nil` 8 | /// * All dictionary keys are `String` 9 | /// * Numbers (`Int`, `Float`, etc.) are not `.nan` or `.infinity` 10 | /// - Parameters: 11 | /// - type: The type of the request to issue 12 | /// - data: The data to accompany with the request, at the top level 13 | /// - queryItems: Query items to include in the call, for REST requests 14 | /// - shouldRetry: Whether to retry the request when a connection change occurs 15 | /// - retryDuration: Maximum duration for which retries are allowed. Defaults to 10 seconds. 16 | /// Pass nil for unlimited retries. 17 | public init( 18 | type: HARequestType, 19 | data: [String: Any] = [:], 20 | queryItems: [URLQueryItem] = [], 21 | shouldRetry: Bool = true, 22 | retryDuration: Measurement? = .init(value: 10, unit: .seconds) 23 | ) { 24 | precondition(JSONSerialization.isValidJSONObject(data)) 25 | self.type = type 26 | self.data = data 27 | self.shouldRetry = shouldRetry 28 | self.queryItems = queryItems 29 | self.retryDuration = retryDuration 30 | } 31 | 32 | /// The type of the request to be issued 33 | public var type: HARequestType 34 | /// Additional top-level data to include in the request 35 | public var data: [String: Any] 36 | /// Whether the request should be retried if the connection closes and reopens 37 | public var shouldRetry: Bool 38 | /// For REST requests, any query items to include in the call 39 | public var queryItems: [URLQueryItem] 40 | /// Maximum duration for which retries are allowed. If nil, retries indefinitely. 41 | public var retryDuration: Measurement? 42 | } 43 | -------------------------------------------------------------------------------- /Tests/HAWebSocketResponseFixture.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | internal enum HAWebSocketResponseFixture { 4 | static func JSONIfy(_ value: String) -> [String: Any] { 5 | // swiftlint:disable:next force_try force_cast 6 | try! JSONSerialization.jsonObject(with: value.data(using: .utf8)!, options: []) as! [String: Any] 7 | } 8 | 9 | static var authRequired = JSONIfy(""" 10 | {"type": "auth_required", "ha_version": "2021.3.0.dev0"} 11 | """) 12 | 13 | static var authOK = JSONIfy(""" 14 | {"type": "auth_ok", "ha_version": "2021.3.0.dev0"} 15 | """) 16 | 17 | static var authOKMissingVersion = JSONIfy(""" 18 | {"type": "auth_ok"} 19 | """) 20 | 21 | static var authInvalid = JSONIfy(""" 22 | {"type": "auth_invalid", "message": "Invalid access token or password"} 23 | """) 24 | 25 | static var responseEmptyResult = JSONIfy(""" 26 | {"id": 1, "type": "result", "success": true, "result": null} 27 | """) 28 | 29 | static var responseDictionaryResult = JSONIfy(""" 30 | {"id": 2, "type": "result", "success": true, "result": {"id": "76ce52a813c44fdf80ee36f926d62328"}} 31 | """) 32 | 33 | static var responseArrayResult = JSONIfy(""" 34 | {"id": 3, "type": "result", "success": true, "result": [{"1": true}, {"2": true}, {"3": true}]} 35 | """) 36 | 37 | static var responseMissingID = JSONIfy(""" 38 | {"type": "result", "success": "true"} 39 | """) 40 | 41 | static var responseInvalidID = JSONIfy(""" 42 | {"id": "lol", "type": "result", "success": "true"} 43 | """) 44 | 45 | static var responseMissingType = JSONIfy(""" 46 | {"id": 9, "success": "true"} 47 | """) 48 | 49 | static var responseInvalidType = JSONIfy(""" 50 | {"id": 10, "type": "unknown", "success": "true"} 51 | """) 52 | 53 | static var responseError = JSONIfy(""" 54 | {"id": 4, "type": "result", "success": false, "error": {"code": "unknown_command", "message": "Unknown command."}} 55 | """) 56 | 57 | static var responseEvent = JSONIfy(""" 58 | {"id": 5, "type": "event", "event": {"result": "ok"}} 59 | """) 60 | 61 | static var responsePong = JSONIfy(""" 62 | {"id": 6, "type": "pong"} 63 | """) 64 | } 65 | -------------------------------------------------------------------------------- /Source/HAError.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// Overall error wrapper for the library 4 | public enum HAError: Error, Equatable, LocalizedError { 5 | /// An error occurred in parsing or other internal handling 6 | case `internal`(debugDescription: String) 7 | /// An underlying error occurred, in e.g. Codable parsing or otherwise. NSError because Equatable is annoying. 8 | case underlying(NSError) 9 | /// An error response from the server indicating a request problem 10 | case external(ExternalError) 11 | 12 | /// A description of the error, see `LocalizedError` or access via `localizedDescription` 13 | public var errorDescription: String? { 14 | switch self { 15 | case let .external(error): return error.message 16 | case let .underlying(error): return error.localizedDescription 17 | case let .internal(debugDescription): return debugDescription 18 | } 19 | } 20 | 21 | /// Description of a server-delivered error 22 | public struct ExternalError: Equatable { 23 | /// The code provided with the error 24 | public var code: String 25 | /// The message provided with the error 26 | public var message: String 27 | 28 | /// Error produced via a malformed response; rare. 29 | public static var invalid: ExternalError { 30 | .init(invalid: ()) 31 | } 32 | 33 | init(_ errorValue: Any?) { 34 | if let error = errorValue as? [String: String], 35 | let code = error["code"], 36 | let message = error["message"] { 37 | self.init(code: code, message: message) 38 | } else { 39 | self.init(invalid: ()) 40 | } 41 | } 42 | 43 | /// Construct an external error 44 | /// 45 | /// Likely just useful for writing unit tests or other constructed situations. 46 | /// 47 | /// - Parameters: 48 | /// - code: The error code 49 | /// - message: The message 50 | public init(code: String, message: String) { 51 | self.code = code 52 | self.message = message 53 | } 54 | 55 | private init(invalid: ()) { 56 | self.code = "invalid_error_response" 57 | self.message = "unable to parse error response" 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /Source/Internal/ResponseController/HAWebSocketResponse.swift: -------------------------------------------------------------------------------- 1 | internal enum HAWebSocketResponse: Equatable { 2 | enum ResponseType: String, Equatable { 3 | case result = "result" 4 | case event = "event" 5 | case authRequired = "auth_required" 6 | case authOK = "auth_ok" 7 | case authInvalid = "auth_invalid" 8 | case pong 9 | } 10 | 11 | enum AuthState: Equatable { 12 | case required 13 | case ok(version: String) 14 | case invalid 15 | } 16 | 17 | case result(identifier: HARequestIdentifier, result: Result) 18 | case event(identifier: HARequestIdentifier, data: HAData) 19 | case auth(AuthState) 20 | 21 | enum ParseError: Error { 22 | case unknownType(Any) 23 | case unknownId(Any) 24 | } 25 | 26 | init(dictionary: [String: Any]) throws { 27 | guard let typeString = dictionary["type"] as? String, let type = ResponseType(rawValue: typeString) else { 28 | throw ParseError.unknownType(dictionary["type"] ?? "(unknown)") 29 | } 30 | 31 | func parseIdentifier() throws -> HARequestIdentifier { 32 | guard let value = (dictionary["id"] as? Int).flatMap(HARequestIdentifier.init(rawValue:)) else { 33 | throw ParseError.unknownId(dictionary["id"] ?? "(unknown)") 34 | } 35 | 36 | return value 37 | } 38 | 39 | switch type { 40 | case .result: 41 | let identifier = try parseIdentifier() 42 | 43 | if dictionary["success"] as? Bool == true { 44 | self = .result(identifier: identifier, result: .success(.init(value: dictionary["result"]))) 45 | } else { 46 | self = .result(identifier: identifier, result: .failure(.external(.init(dictionary["error"])))) 47 | } 48 | case .pong: 49 | let identifier = try parseIdentifier() 50 | self = .result(identifier: identifier, result: .success(.empty)) 51 | case .event: 52 | let identifier = try parseIdentifier() 53 | self = .event(identifier: identifier, data: .init(value: dictionary["event"])) 54 | case .authRequired: 55 | self = .auth(.required) 56 | case .authOK: 57 | self = .auth(.ok(version: dictionary["ha_version"] as? String ?? "unknown")) 58 | case .authInvalid: 59 | self = .auth(.invalid) 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /Tests/HARequestInvocationSingle.test.swift: -------------------------------------------------------------------------------- 1 | @testable import HAKit 2 | import XCTest 3 | 4 | internal class HARequestInvocationSingleTests: XCTestCase { 5 | func testCancelRequest() { 6 | let invocation = HARequestInvocationSingle( 7 | request: .init(type: .callService, data: [:]), 8 | completion: { _ in } 9 | ) 10 | XCTAssertNil(invocation.cancelRequest()) 11 | } 12 | 13 | func testNeedsAssignmentBySending() { 14 | let invocation = HARequestInvocationSingle( 15 | request: .init(type: .callService, data: [:]), 16 | completion: { _ in } 17 | ) 18 | XCTAssertTrue(invocation.needsAssignment) 19 | invocation.identifier = 44 20 | XCTAssertFalse(invocation.needsAssignment) 21 | } 22 | 23 | func testNeedsAssignmentByCanceling() { 24 | class TestClass {} 25 | var value: TestClass? = TestClass() 26 | weak var weakValue = value 27 | XCTAssertNotNil(weakValue) 28 | 29 | let invocation = HARequestInvocationSingle( 30 | request: .init(type: .callService, data: [:]), 31 | completion: { [value] _ in withExtendedLifetime(value) {} } 32 | ) 33 | XCTAssertTrue(invocation.needsAssignment) 34 | invocation.cancel() 35 | XCTAssertFalse(invocation.needsAssignment) 36 | 37 | value = nil 38 | XCTAssertNil(weakValue) 39 | } 40 | 41 | func testCompletionInvokedOnceAndCleared() throws { 42 | let completionExpectation = expectation(description: "completion") 43 | 44 | class TestClass {} 45 | var value: TestClass? = TestClass() 46 | weak var weakValue = value 47 | XCTAssertNotNil(weakValue) 48 | 49 | let invocation = HARequestInvocationSingle( 50 | request: .init(type: .callService, data: [:]), 51 | completion: { [value] result in 52 | switch result { 53 | case .success(.empty): break 54 | default: XCTFail("expected .success(empty), got \(result)") 55 | } 56 | 57 | withExtendedLifetime(value) { 58 | completionExpectation.fulfill() 59 | } 60 | } 61 | ) 62 | 63 | value = nil 64 | XCTAssertNotNil(weakValue) 65 | 66 | invocation.resolve(.success(.empty)) 67 | waitForExpectations(timeout: 10.0) 68 | XCTAssertNil(weakValue) 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /Tests/HAConnectionConfiguration.test.swift: -------------------------------------------------------------------------------- 1 | @testable import HAKit 2 | import XCTest 3 | 4 | internal class HAConnectionConfigurationTests: XCTestCase { 5 | func testNilConnectionInfo() { 6 | let configuration = HAConnectionConfiguration( 7 | connectionInfo: { nil }, 8 | fetchAuthToken: { _ in fatalError() } 9 | ) 10 | XCTAssertNil(configuration.connectionInfo()) 11 | } 12 | 13 | func testConnectionInfo() throws { 14 | let url1 = URL(string: "http://example.com/1")! 15 | let url2 = URL(string: "http://example.com/2")! 16 | 17 | var configuration = HAConnectionConfiguration( 18 | connectionInfo: { try? .init(url: url1) }, 19 | fetchAuthToken: { _ in fatalError() } 20 | ) 21 | 22 | try XCTAssertEqual(XCTUnwrap(configuration.connectionInfo()).url, url1) 23 | 24 | configuration.connectionInfo = { try? .init(url: url2) } 25 | try XCTAssertEqual(XCTUnwrap(configuration.connectionInfo()).url, url2) 26 | } 27 | 28 | func testFetchAuthToken() throws { 29 | enum TestError: Error { 30 | case test 31 | } 32 | 33 | let string1 = "string1" 34 | let string2 = "string2" 35 | let error1 = TestError.test 36 | 37 | var configuration = HAConnectionConfiguration( 38 | connectionInfo: { fatalError() }, 39 | fetchAuthToken: { $0(.success(string1)) } 40 | ) 41 | 42 | let expectation1 = expectation(description: "string1") 43 | 44 | configuration.fetchAuthToken { result in 45 | XCTAssertEqual(try? result.get(), string1) 46 | expectation1.fulfill() 47 | } 48 | 49 | wait(for: [expectation1], timeout: 10.0) 50 | 51 | let expectation2 = expectation(description: "string2") 52 | configuration.fetchAuthToken = { $0(.success(string2)) } 53 | configuration.fetchAuthToken { result in 54 | XCTAssertEqual(try? result.get(), string2) 55 | expectation2.fulfill() 56 | } 57 | wait(for: [expectation2], timeout: 10.0) 58 | 59 | let expectation3 = expectation(description: "error1") 60 | configuration.fetchAuthToken = { $0(.failure(error1)) } 61 | configuration.fetchAuthToken { result in 62 | switch result { 63 | case .success: XCTFail("encountered success when expecting error") 64 | case let .failure(error): 65 | XCTAssertEqual(error as? TestError, error1) 66 | } 67 | expectation3.fulfill() 68 | } 69 | wait(for: [expectation3], timeout: 10.0) 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /Source/Convenience/States.swift: -------------------------------------------------------------------------------- 1 | public extension HATypedSubscription { 2 | /// Listen for state changes of all entities 3 | /// 4 | /// This is a convenient version of listening to the `.stateChanged` event, but with parsed response values. 5 | /// 6 | /// - Returns: A typed subscriptions that can be sent via `HAConnection` 7 | static func stateChanged() -> HATypedSubscription { 8 | .init(request: .init(type: .subscribeEvents, data: [ 9 | "event_type": HAEventType.stateChanged.rawValue!, 10 | ])) 11 | } 12 | 13 | /// Listen for compressed state changes of all entities 14 | /// - Parameter data: The data passed to connection request 15 | /// - Returns: A typed subscriptions that can be sent via `HAConnection` 16 | static func subscribeEntities(data: [String: Any]) -> HATypedSubscription { 17 | .init(request: .init(type: .subscribeEntities, data: data)) 18 | } 19 | } 20 | 21 | /// State changed event 22 | public struct HAResponseEventStateChanged: HADataDecodable { 23 | /// The underlying event and the information it contains 24 | /// 25 | /// - TODO: should this be moved from composition to inheritence? 26 | public var event: HAResponseEvent 27 | /// The entity ID which is changing 28 | public var entityId: String 29 | /// The old state of the entity, if there was one 30 | public var oldState: HAEntity? 31 | /// The new state of the entity, if there is one 32 | public var newState: HAEntity? 33 | 34 | /// Create with data 35 | /// - Parameter data: The data from the server 36 | /// - Throws: If any required keys are missing 37 | public init(data: HAData) throws { 38 | let event = try HAResponseEvent(data: data) 39 | let eventData = HAData.dictionary(event.data) 40 | 41 | try self.init( 42 | event: event, 43 | entityId: eventData.decode("entity_id"), 44 | oldState: try? eventData.decode("old_state"), 45 | newState: try? eventData.decode("new_state") 46 | ) 47 | } 48 | 49 | /// Create with information 50 | /// - Parameters: 51 | /// - event: The event 52 | /// - entityId: The entity id 53 | /// - oldState: The old state, or nil 54 | /// - newState: The new state, or nil 55 | public init( 56 | event: HAResponseEvent, 57 | entityId: String, 58 | oldState: HAEntity?, 59 | newState: HAEntity? 60 | ) { 61 | self.event = event 62 | self.entityId = entityId 63 | self.oldState = oldState 64 | self.newState = newState 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /Tests/RenderTemplate.test.swift: -------------------------------------------------------------------------------- 1 | @testable import HAKit 2 | import XCTest 3 | 4 | internal class RenderTemplateTests: XCTestCase { 5 | func testRequestWithJustTemplate() { 6 | let request = HATypedSubscription.renderTemplate("{{ test() }}") 7 | XCTAssertEqual(request.request.type, .renderTemplate) 8 | XCTAssertEqual(request.request.shouldRetry, true) 9 | XCTAssertEqual(request.request.data["template"] as? String, "{{ test() }}") 10 | XCTAssertNil(request.request.data["timeout"]) 11 | XCTAssertEqual((request.request.data["variables"] as? [String: Any])?.count, 0) 12 | } 13 | 14 | func testRequestWithExtras() throws { 15 | let request = HATypedSubscription.renderTemplate( 16 | "{{ test() }}", 17 | variables: ["a": 1, "b": true], 18 | timeout: .init(value: 3, unit: .seconds) 19 | ) 20 | XCTAssertEqual(request.request.type, .renderTemplate) 21 | XCTAssertEqual(request.request.shouldRetry, true) 22 | XCTAssertEqual(request.request.data["template"] as? String, "{{ test() }}") 23 | XCTAssertEqual(request.request.data["timeout"] as? Double ?? -1, 3, accuracy: 0.01) 24 | 25 | let variables = try XCTUnwrap(request.request.data["variables"] as? [String: Any]) 26 | XCTAssertEqual(variables["a"] as? Int, 1) 27 | XCTAssertEqual(variables["b"] as? Bool, true) 28 | } 29 | 30 | func testResponseFull() throws { 31 | let data = HAData(testJsonString: """ 32 | { 33 | "result": "string value", 34 | "listeners": { 35 | "all": true, 36 | "entities": [ 37 | "sun.sun" 38 | ], 39 | "domains": [ 40 | "device_tracker", 41 | "automation" 42 | ], 43 | "time": true 44 | } 45 | } 46 | """) 47 | let response = try HAResponseRenderTemplate(data: data) 48 | XCTAssertEqual(response.result as? String, "string value") 49 | XCTAssertEqual(response.listeners.all, true) 50 | XCTAssertEqual(response.listeners.time, true) 51 | XCTAssertEqual(response.listeners.entities, ["sun.sun"]) 52 | XCTAssertEqual(response.listeners.domains, ["device_tracker", "automation"]) 53 | } 54 | 55 | func testResponseMinimal() throws { 56 | let data = HAData(testJsonString: """ 57 | { 58 | "result": true, 59 | "listeners": {} 60 | } 61 | """) 62 | let response = try HAResponseRenderTemplate(data: data) 63 | XCTAssertEqual(response.result as? Bool, true) 64 | XCTAssertEqual(response.listeners.all, false) 65 | XCTAssertEqual(response.listeners.time, false) 66 | XCTAssertEqual(response.listeners.entities, []) 67 | XCTAssertEqual(response.listeners.domains, []) 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /Tests/Event.test.swift: -------------------------------------------------------------------------------- 1 | @testable import HAKit 2 | import XCTest 3 | 4 | internal class EventTests: XCTestCase { 5 | func testRequestAll() { 6 | let request = HATypedSubscription.events(.all) 7 | XCTAssertEqual(request.request.type, .subscribeEvents) 8 | XCTAssertEqual(request.request.shouldRetry, true) 9 | XCTAssertEqual(request.request.data.count, 0) 10 | } 11 | 12 | func testRequestSpecific() { 13 | let request = HATypedSubscription.events("test") 14 | XCTAssertEqual(request.request.type, .subscribeEvents) 15 | XCTAssertEqual(request.request.shouldRetry, true) 16 | XCTAssertEqual(request.request.data["event_type"] as? String, "test") 17 | } 18 | 19 | func testUnsubscribe() { 20 | let request = HATypedRequest.unsubscribe(33) 21 | XCTAssertEqual(request.request.type, .unsubscribeEvents) 22 | XCTAssertEqual(request.request.shouldRetry, false) 23 | XCTAssertEqual(request.request.data["subscription"] as? Int, 33) 24 | } 25 | 26 | func testResponseFull() throws { 27 | let data = HAData(testJsonString: """ 28 | { 29 | "event_type": "state_changed", 30 | "data": {"test": true}, 31 | "origin": "LOCAL", 32 | "time_fired": "2021-02-24T04:31:10.045916+00:00", 33 | "context": { 34 | "id": "ebc9bf93dd90efc0770f1dc49096788f", 35 | "parent_id": "9e47ec85012dc304ad412ffa78c54c196ff156a1", 36 | "user_id": "76ce52a813c44fdf80ee36f926d62328" 37 | } 38 | } 39 | """) 40 | let response = try HAResponseEvent(data: data) 41 | XCTAssertEqual(response.type, .stateChanged) 42 | XCTAssertEqual(response.data["test"] as? Bool, true) 43 | XCTAssertEqual(response.origin, .local) 44 | XCTAssertEqual(response.context.id, "ebc9bf93dd90efc0770f1dc49096788f") 45 | XCTAssertEqual(response.context.parentId, "9e47ec85012dc304ad412ffa78c54c196ff156a1") 46 | XCTAssertEqual(response.context.userId, "76ce52a813c44fdf80ee36f926d62328") 47 | } 48 | 49 | func testResponseMinimal() throws { 50 | let data = HAData(testJsonString: """ 51 | { 52 | "event_type": "state_changed", 53 | "origin": "REMOTE", 54 | "time_fired": "2021-02-24T04:31:10.045916+00:00", 55 | "context": { 56 | "id": "ebc9bf93dd90efc0770f1dc49096788f" 57 | } 58 | } 59 | """) 60 | let response = try HAResponseEvent(data: data) 61 | XCTAssertEqual(response.type, .stateChanged) 62 | XCTAssertEqual(response.data.isEmpty, true) 63 | XCTAssertEqual(response.origin, .remote) 64 | XCTAssertEqual(response.context.id, "ebc9bf93dd90efc0770f1dc49096788f") 65 | XCTAssertEqual(response.context.parentId, nil) 66 | XCTAssertEqual(response.context.userId, nil) 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /Source/Caches/HACacheKeyStates.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// Key for the cache 4 | internal struct HACacheKeyStates: HACacheKey { 5 | static func create(connection: HAConnection, data: [String: Any]) -> HACache { 6 | .init( 7 | connection: connection, 8 | subscribe: 9 | .init( 10 | subscription: .subscribeEntities(data: data), 11 | transform: { info in 12 | .replace(processUpdates( 13 | info: info, 14 | shouldResetEntities: info.subscriptionPhase == .initial 15 | )) 16 | } 17 | ) 18 | ) 19 | } 20 | 21 | /// Process updates from the compressed state to HAEntity 22 | /// - Parameters: 23 | /// - info: The compressed state update and the current cached states 24 | /// - shouldResetEntities: True if current state needs to be ignored (e.g. re-connection) 25 | /// - Returns: HAEntity cached states 26 | /// Logic from: https://github.com/home-assistant/home-assistant-js-websocket/blob/master/lib/entities.ts 27 | // swiftlint: disable cyclomatic_complexity 28 | static func processUpdates( 29 | info: HACacheTransformInfo, 30 | shouldResetEntities: Bool 31 | ) -> HACachedStates { 32 | var updatedEntities: [String: HAEntity] = [:] 33 | 34 | if !shouldResetEntities, let currentEntities = info.current { 35 | updatedEntities = currentEntities.allByEntityId 36 | } 37 | 38 | if let additions = info.incoming.add { 39 | for (entityId, updates) in additions { 40 | if var currentState = updatedEntities[entityId] { 41 | currentState.update(from: updates) 42 | updatedEntities[entityId] = currentState 43 | } else { 44 | do { 45 | let newEntity = try updates.asEntity(entityId: entityId) 46 | updatedEntities[entityId] = newEntity 47 | } catch { 48 | HAGlobal.log(.error, "[Update-To-Entity-Error] Failed adding new entity: \(error)") 49 | } 50 | } 51 | } 52 | } 53 | 54 | if let subtractions = info.incoming.remove { 55 | for entityId in subtractions { 56 | updatedEntities.removeValue(forKey: entityId) 57 | } 58 | } 59 | 60 | if let changes = info.incoming.change { 61 | for (entityId, diff) in changes { 62 | guard var entityState = updatedEntities[entityId] else { continue } 63 | 64 | if let toAdd = diff.additions { 65 | entityState.add(toAdd) 66 | } 67 | 68 | if let toRemove = diff.subtractions { 69 | entityState.subtract(toRemove) 70 | } 71 | 72 | updatedEntities[entityId] = entityState 73 | } 74 | } 75 | 76 | return .init(entitiesDictionary: updatedEntities) 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /Tests/CurrentUser.test.swift: -------------------------------------------------------------------------------- 1 | @testable import HAKit 2 | import XCTest 3 | 4 | internal class CurrentUserTests: XCTestCase { 5 | func testRequest() { 6 | let request = HATypedRequest.currentUser() 7 | XCTAssertEqual(request.request.type, .currentUser) 8 | XCTAssertEqual(request.request.data.count, 0) 9 | XCTAssertEqual(request.request.shouldRetry, true) 10 | } 11 | 12 | func testResponseWithFullValues() throws { 13 | let data = HAData(testJsonString: """ 14 | { 15 | "id": "76ce52a813c44fdf80ee36f926d62328", 16 | "name": "Dev User", 17 | "is_owner": false, 18 | "is_admin": true, 19 | "credentials": [ 20 | { 21 | "auth_provider_type": "homeassistant", 22 | "auth_provider_id": null 23 | }, 24 | { 25 | "auth_provider_type": "test", 26 | "auth_provider_id": "abc" 27 | } 28 | ], 29 | "mfa_modules": [ 30 | { 31 | "id": "totp", 32 | "name": "Authenticator app", 33 | "enabled": false 34 | }, 35 | { 36 | "id": "sms", 37 | "name": "SMS", 38 | "enabled": true 39 | }, 40 | ] 41 | } 42 | """) 43 | let user = try HAResponseCurrentUser(data: data) 44 | XCTAssertEqual(user.id, "76ce52a813c44fdf80ee36f926d62328") 45 | XCTAssertEqual(user.name, "Dev User") 46 | XCTAssertEqual(user.isOwner, false) 47 | XCTAssertEqual(user.isAdmin, true) 48 | XCTAssertEqual(user.credentials.count, 2) 49 | 50 | let credential1 = try user.credentials.get(throwing: 0) 51 | let credential2 = try user.credentials.get(throwing: 1) 52 | XCTAssertEqual(credential1.id, nil) 53 | XCTAssertEqual(credential1.type, "homeassistant") 54 | XCTAssertEqual(credential2.id, "abc") 55 | XCTAssertEqual(credential2.type, "test") 56 | 57 | let mfaModule1 = try user.mfaModules.get(throwing: 0) 58 | let mfaModule2 = try user.mfaModules.get(throwing: 1) 59 | XCTAssertEqual(mfaModule1.id, "totp") 60 | XCTAssertEqual(mfaModule1.name, "Authenticator app") 61 | XCTAssertEqual(mfaModule1.isEnabled, false) 62 | XCTAssertEqual(mfaModule2.id, "sms") 63 | XCTAssertEqual(mfaModule2.name, "SMS") 64 | XCTAssertEqual(mfaModule2.isEnabled, true) 65 | } 66 | 67 | func testResponseWithMinimalValues() throws { 68 | let data = HAData(testJsonString: """ 69 | { 70 | "id": "76ce52a813c44fdf80ee36f926d62328" 71 | } 72 | """) 73 | let user = try HAResponseCurrentUser(data: data) 74 | XCTAssertEqual(user.id, "76ce52a813c44fdf80ee36f926d62328") 75 | XCTAssertEqual(user.name, nil) 76 | XCTAssertEqual(user.isOwner, false) 77 | XCTAssertEqual(user.isAdmin, false) 78 | XCTAssertEqual(user.credentials.count, 0) 79 | XCTAssertEqual(user.mfaModules.count, 0) 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /Source/Requests/HARequestType.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// The command to issue 4 | public enum HARequestType: Hashable, Comparable, ExpressibleByStringLiteral { 5 | /// Sent over WebSocket, the command of the request 6 | case webSocket(String) 7 | /// Sent over REST, the HTTP method to use and the post-`api/` path 8 | case rest(HAHTTPMethod, String) 9 | /// Sent over WebSocket, the stt binary handler id 10 | case sttData(HASttHandlerId) 11 | 12 | /// Create a WebSocket request type by string literal 13 | /// - Parameter value: The name of the WebSocket command 14 | public init(stringLiteral value: StringLiteralType) { 15 | self = .webSocket(value) 16 | } 17 | 18 | /// The command of the request, agnostic of protocol type 19 | public var command: String { 20 | switch self { 21 | case let .webSocket(command), let .rest(_, command): 22 | return command 23 | case .sttData: 24 | return "" 25 | } 26 | } 27 | 28 | /// The request is issued outside of the lifecycle of a connection 29 | public var isPerpetual: Bool { 30 | switch self { 31 | case .webSocket, .sttData: return false 32 | case .rest: return true 33 | } 34 | } 35 | 36 | /// Sort the request type by command name 37 | /// - Parameters: 38 | /// - lhs: The first type to compare 39 | /// - rhs: The second value to compare 40 | /// - Returns: Whether the first type preceeds the second 41 | public static func < (lhs: HARequestType, rhs: HARequestType) -> Bool { 42 | switch (lhs, rhs) { 43 | case (.webSocket, .rest): return true 44 | case (.rest, .webSocket): return false 45 | case let (.webSocket(lhsCommand), .webSocket(rhsCommand)), 46 | let (.rest(_, lhsCommand), .rest(_, rhsCommand)): 47 | return lhsCommand < rhsCommand 48 | case (.sttData, _), (_, .sttData): 49 | return false 50 | } 51 | } 52 | 53 | // MARK: - Requests 54 | 55 | /// `call_service` 56 | public static var callService: Self = "call_service" 57 | /// `auth/current_user` 58 | public static var currentUser: Self = "auth/current_user" 59 | /// `get_states` 60 | public static var getStates: Self = "get_states" 61 | /// `get_config` 62 | public static var getConfig: Self = "get_config" 63 | /// `get_services` 64 | public static var getServices: Self = "get_services" 65 | 66 | // MARK: - Subscription Handling 67 | 68 | /// `subscribe_events` 69 | public static var subscribeEvents: Self = "subscribe_events" 70 | /// `unsubscribe_events` 71 | public static var unsubscribeEvents: Self = "unsubscribe_events" 72 | /// `subscribe_entities` 73 | public static var subscribeEntities: Self = "subscribe_entities" 74 | 75 | // MARK: - Subscriptions 76 | 77 | /// `render_template` 78 | public static var renderTemplate: Self = "render_template" 79 | 80 | // MARK: - Internal 81 | 82 | /// `ping` 83 | /// This will always get a success response, when a response is received. 84 | public static var ping: Self = "ping" 85 | 86 | /// `auth` 87 | /// This is likely not useful for external consumers as this is handled automatically on connection. 88 | public static var auth: Self = "auth" 89 | } 90 | -------------------------------------------------------------------------------- /Source/Data/HAData.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// Data from a response 4 | /// 5 | /// The root-level information in either the `result` for individual requests or `event` for subscriptions. 6 | public enum HAData: Equatable { 7 | /// A dictionary response. 8 | /// - SeeAlso: `get(_:)`and associated methods 9 | case dictionary([String: Any]) 10 | /// An array response. 11 | case array([HAData]) 12 | /// Any other response, e.g. a string or number 13 | case primitive(Any) 14 | /// An empty response, such as `null` 15 | case empty 16 | 17 | /// Convert an unknown value type into an enum case 18 | /// For use with direct response handling. 19 | /// 20 | /// - Parameter value: The value to convert 21 | public init(value: Any?) { 22 | if let value = value as? [String: Any] { 23 | self = .dictionary(value) 24 | } else if let value = value as? [Any] { 25 | self = .array(value.map(Self.init(value:))) 26 | } else if let value = value, !(value is NSNull) { 27 | self = .primitive(value) 28 | } else { 29 | self = .empty 30 | } 31 | } 32 | 33 | public static func == (lhs: HAData, rhs: HAData) -> Bool { 34 | switch (lhs, rhs) { 35 | case (.empty, .empty): 36 | return true 37 | case let (.primitive(l), .primitive(r)): 38 | if let l = l as? Int, let r = r as? Int { 39 | return l == r 40 | } else if let l = l as? String, let r = r as? String { 41 | return l == r 42 | } else if let l = l as? Double, let r = r as? Double { 43 | return l == r 44 | } else if let l = l as? Bool, let r = r as? Bool { 45 | return l == r 46 | } 47 | 48 | return false 49 | case let (.array(lhsArray), .array(rhsArray)): 50 | return lhsArray == rhsArray 51 | case let (.dictionary(lhsDict), .dictionary(rhsDict)): 52 | // we know the dictionary can be represented in JSON, so take advantage of this fact 53 | do { 54 | func serialize(value: [String: Any]) throws -> Data? { 55 | enum InvalidObject: Error { 56 | case invalidObject 57 | } 58 | 59 | guard JSONSerialization.isValidJSONObject(value) else { 60 | // this throws an objective-c exception if it tries to serialize an invalid type 61 | throw InvalidObject.invalidObject 62 | } 63 | 64 | return try JSONSerialization.data(withJSONObject: value, options: [.sortedKeys]) 65 | } 66 | 67 | return try serialize(value: lhsDict) == serialize(value: rhsDict) 68 | } catch { 69 | return false 70 | } 71 | case (.dictionary, .array), 72 | (.dictionary, .primitive), 73 | (.dictionary, .empty), 74 | (.array, .dictionary), 75 | (.array, .primitive), 76 | (.array, .empty), 77 | (.empty, .dictionary), 78 | (.empty, .array), 79 | (.empty, .primitive), 80 | (.primitive, .dictionary), 81 | (.primitive, .array), 82 | (.primitive, .empty): 83 | return false 84 | } 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /Tests/HACachesContainer.test.swift: -------------------------------------------------------------------------------- 1 | @testable import HAKit 2 | import XCTest 3 | #if SWIFT_PACKAGE 4 | import HAKit_Mocks 5 | #endif 6 | 7 | internal class HACachesContainerTests: XCTestCase { 8 | private var connection: HAMockConnection! 9 | private var container: HACachesContainer! 10 | 11 | override func setUp() { 12 | super.setUp() 13 | connection = HAMockConnection() 14 | container = HACachesContainer(connection: connection) 15 | } 16 | 17 | func testCreate() throws { 18 | let cache1 = container[Key1.self] 19 | XCTAssertEqual(try ObjectIdentifier(XCTUnwrap(cache1.connection)), ObjectIdentifier(connection)) 20 | 21 | XCTAssertEqual(cache1.populateInfo?.request.type, "key1_pop") 22 | XCTAssertEqual(cache1.subscribeInfo?.count, 1) 23 | XCTAssertEqual(cache1.subscribeInfo?.first?.request.type, "key1_sub") 24 | } 25 | 26 | func testSingleCacheCreated() { 27 | let cache1a = container[Key1.self] 28 | let cache2a = container[Key2.self] 29 | let cache1b = container[Key1.self] 30 | let cache2b = container[Key2.self] 31 | 32 | XCTAssertEqual(ObjectIdentifier(cache1a), ObjectIdentifier(cache1b)) 33 | XCTAssertEqual(ObjectIdentifier(cache2a), ObjectIdentifier(cache2b)) 34 | } 35 | 36 | func testSameTypeNotDuplicate() { 37 | let cache1 = container[Key1.self] 38 | let cache2 = container[Key2.self] 39 | XCTAssertNotEqual(ObjectIdentifier(cache1), ObjectIdentifier(cache2)) 40 | } 41 | 42 | func testSameKeyWithDifferentDataReturnsDifferentCache() { 43 | _ = container.states(["abc": "def"]) 44 | _ = container.states(["123": "456"]) 45 | 46 | XCTAssertEqual(container.values.first?.value.count, 2) 47 | } 48 | 49 | func testSameKeyWithSameDataReturnsSameCache() { 50 | _ = container.states(["abc": "def"]) 51 | _ = container.states(["abc": "def"]) 52 | 53 | XCTAssertEqual(container.values.first?.value.count, 1) 54 | } 55 | } 56 | 57 | private struct Key1: HACacheKey { 58 | static func create(connection: HAConnection, data: [String: Any]) -> HACache { 59 | .init( 60 | connection: connection, 61 | populate: HACachePopulateInfo( 62 | request: HATypedRequest(request: .init(type: "key1_pop", data: [:])), 63 | transform: \.incoming 64 | ), 65 | subscribe: HACacheSubscribeInfo( 66 | subscription: HATypedSubscription(request: .init(type: "key1_sub", data: [:])), 67 | transform: { .replace($0.incoming) } 68 | ) 69 | ) 70 | } 71 | } 72 | 73 | private struct Value1: HADataDecodable { 74 | init(data: HAData) throws {} 75 | } 76 | 77 | private struct Key2: HACacheKey { 78 | static func create(connection: HAConnection, data: [String: Any]) -> HACache { 79 | .init( 80 | connection: connection, 81 | populate: HACachePopulateInfo( 82 | request: HATypedRequest(request: .init(type: "key1_pop", data: [:])), 83 | transform: \.incoming 84 | ), 85 | subscribe: HACacheSubscribeInfo( 86 | subscription: HATypedSubscription(request: .init(type: "key1_sub", data: [:])), 87 | transform: { .replace($0.incoming) } 88 | ) 89 | ) 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /Tests/HARequestInvocationSubscription.test.swift: -------------------------------------------------------------------------------- 1 | @testable import HAKit 2 | import XCTest 3 | 4 | internal class HARequestInvocationSubscriptionTests: XCTestCase { 5 | func testCancelRequestBeforeAssigned() { 6 | let invocation = HARequestInvocationSubscription( 7 | request: .init(type: .callService, data: [:]), 8 | initiated: { _ in }, 9 | handler: { _, _ in } 10 | ) 11 | XCTAssertNil(invocation.cancelRequest()) 12 | } 13 | 14 | func testCancelRequestAfterAssigned() throws { 15 | let invocation = HARequestInvocationSubscription( 16 | request: .init(type: .callService, data: [:]), 17 | initiated: { _ in }, 18 | handler: { _, _ in } 19 | ) 20 | invocation.identifier = 77 21 | 22 | let request = try XCTUnwrap(invocation.cancelRequest()) 23 | XCTAssertEqual(request.request.type, .unsubscribeEvents) 24 | XCTAssertEqual(request.request.data["subscription"] as? Int, 77) 25 | } 26 | 27 | func testNeedsAssignmentBySending() { 28 | let invocation = HARequestInvocationSubscription( 29 | request: .init(type: .callService, data: [:]), 30 | initiated: { _ in }, 31 | handler: { _, _ in } 32 | ) 33 | XCTAssertTrue(invocation.needsAssignment) 34 | invocation.identifier = 44 35 | XCTAssertFalse(invocation.needsAssignment) 36 | } 37 | 38 | func testNeedsAssignmentByCanceling() { 39 | class TestClass {} 40 | var value: TestClass? = TestClass() 41 | weak var weakValue = value 42 | XCTAssertNotNil(weakValue) 43 | 44 | let invocation = HARequestInvocationSubscription( 45 | request: .init(type: .callService, data: [:]), 46 | initiated: { [value] _ in withExtendedLifetime(value) {} }, 47 | handler: { [value] _, _ in withExtendedLifetime(value) {} } 48 | ) 49 | XCTAssertTrue(invocation.needsAssignment) 50 | invocation.cancel() 51 | XCTAssertFalse(invocation.needsAssignment) 52 | 53 | value = nil 54 | XCTAssertNil(weakValue) 55 | } 56 | 57 | func testResolveHandler() { 58 | var resolved: Result? 59 | 60 | let invocation = HARequestInvocationSubscription( 61 | request: .init(type: .callService, data: [:]), 62 | initiated: { resolved = $0 }, 63 | handler: { _, _ in } 64 | ) 65 | 66 | invocation.resolve(.success(.empty)) 67 | 68 | if case .success(.empty) = resolved { 69 | // pass 70 | } else { 71 | XCTFail("expected success with empty, got \(String(describing: resolved))") 72 | } 73 | 74 | invocation.resolve(.failure(.internal(debugDescription: "test"))) 75 | 76 | if case .failure(.internal(debugDescription: "test")) = resolved { 77 | // pass 78 | } else { 79 | XCTFail("expected failure, got \(String(describing: resolved))") 80 | } 81 | } 82 | 83 | func testHandler() throws { 84 | var invoked: (token: HACancellable, event: HAData)? 85 | 86 | let invocation = HARequestInvocationSubscription( 87 | request: .init(type: .callService, data: [:]), 88 | initiated: { _ in }, 89 | handler: { invoked = (token: $0, event: $1) } 90 | ) 91 | 92 | var handler1Called = false 93 | 94 | invocation.invoke(token: .init(handler: { handler1Called = true }), event: .init(value: ["ok": "yeah"])) 95 | 96 | XCTAssertFalse(handler1Called) 97 | invoked?.token.cancel() 98 | XCTAssertTrue(handler1Called) 99 | 100 | XCTAssertEqual(try XCTUnwrap(invoked).event.decode("ok") as String, "yeah") 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /Source/Internal/HAConnectionImpl+Responses.swift: -------------------------------------------------------------------------------- 1 | import Starscream 2 | 3 | extension HAConnectionImpl: Starscream.WebSocketDelegate { 4 | func didReceive(event: Starscream.WebSocketEvent, client: any Starscream.WebSocketClient) { 5 | responseController.didReceive(event: event) 6 | } 7 | } 8 | 9 | extension HAConnectionImpl { 10 | private func sendAuthToken() { 11 | let lock = HAResetLock<(Result) -> Void> { [self] result in 12 | switch result { 13 | case let .success(token): 14 | sendRaw( 15 | identifier: nil, 16 | request: .init(type: .auth, data: ["access_token": token]) 17 | ) 18 | case let .failure(error): 19 | HAGlobal.log(.error, "delegate failed to provide access token \(error), bailing") 20 | disconnect(error: error) 21 | } 22 | } 23 | 24 | configuration.fetchAuthToken { result in 25 | lock.pop()?(result) 26 | } 27 | } 28 | } 29 | 30 | extension HAConnectionImpl: HAResponseControllerDelegate { 31 | func responseController( 32 | _ responseController: HAResponseController, 33 | didReceive response: HAWebSocketResponse 34 | ) { 35 | switch response { 36 | case .auth(.invalid): 37 | // Authentication failed - disconnect with rejected context to block automatic retries 38 | HAGlobal.log(.error, "authentication failed with invalid token") 39 | disconnect( 40 | context: .rejected, 41 | error: HAError.internal(debugDescription: "authentication failed, invalid token") 42 | ) 43 | case .auth: 44 | // we send auth token pre-emptively, so we don't need to care about the other auth messages 45 | // note that we do watch for auth->command phase change so we can re-activate pending requests 46 | break 47 | case let .event(identifier: identifier, data: data): 48 | if let subscription = requestController.subscription(for: identifier) { 49 | callbackQueue.async { [self] in 50 | subscription.invoke(token: HACancellableImpl { [requestController] in 51 | requestController.cancel(subscription) 52 | }, event: data) 53 | } 54 | } else { 55 | HAGlobal.log(.error, "unable to find subscription for identifier \(identifier)") 56 | } 57 | case let .result(identifier: identifier, result: result): 58 | if let request = requestController.single(for: identifier) { 59 | callbackQueue.async { 60 | request.resolve(result) 61 | } 62 | 63 | requestController.clear(invocation: request) 64 | } else if let subscription = requestController.subscription(for: identifier) { 65 | callbackQueue.async { 66 | subscription.resolve(result) 67 | } 68 | } else { 69 | HAGlobal.log(.error, "unable to find request for identifier \(identifier)") 70 | } 71 | } 72 | } 73 | 74 | func responseController( 75 | _ responseController: HAResponseController, 76 | didTransitionTo phase: HAResponseControllerPhase 77 | ) { 78 | switch phase { 79 | case .auth: 80 | sendAuthToken() 81 | notifyState() 82 | case .command: 83 | reconnectManager.didFinishConnect() 84 | requestController.prepare() 85 | notifyState() 86 | case let .disconnected(error, forReset: reset): 87 | if !reset { 88 | // state will notify from this method call 89 | disconnect(error: error) 90 | } 91 | requestController.resetActive() 92 | } 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /Source/Caches/HACachesContainer.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// A cache key for `HACachesContainer` 4 | public protocol HACacheKey { 5 | /// The value type in the cache, e.g. `T` in `HACache` 6 | associatedtype Value 7 | /// Create a cache on a particular connection 8 | /// 9 | /// This is called exactly once per connection per cache key. 10 | /// 11 | /// - Parameters: 12 | /// - connection: The connection to create on 13 | /// - data: The data passed to connection request 14 | /// - Returns: The cache you want to associate with the key 15 | static func create( 16 | connection: HAConnection, 17 | data: [String: Any] 18 | ) -> HACache 19 | } 20 | 21 | /// Container for caches 22 | /// 23 | /// You can create your own cache accessible in this container like so: 24 | /// 25 | /// Create a key to represent your cache: 26 | /// 27 | /// ```swift 28 | /// struct YourValueTypeKey: HACacheKey { 29 | /// func create(connection: HAConnection) -> HACache { 30 | /// return HACache(connection: connection, populate: …, subscribe: …) 31 | /// } 32 | /// } 33 | /// ``` 34 | /// 35 | /// Add a convenience getter to the container itself: 36 | /// 37 | /// ```swift 38 | /// extension HACachesContainer { 39 | /// var yourValueType: HACache { self[YourValueTypeKey.self] } 40 | /// } 41 | /// ``` 42 | /// 43 | /// Then, access it from a connection like `connection.caches.yourValueType`. 44 | public class HACachesContainer { 45 | struct CacheEntry { 46 | let data: [String: Any] 47 | let cache: Any 48 | } 49 | 50 | /// Our current initialized caches. We key by the ObjectIdentifier of the meta type, which guarantees a unique 51 | /// cache entry per key since the identifier is globally unique per type. 52 | private(set) var values: [ObjectIdentifier: [CacheEntry]] = [:] 53 | /// The connection we're chained off. This is unowned to avoid a cyclic reference. We expect to crash in this case. 54 | internal unowned let connection: HAConnection 55 | 56 | /// Create the caches container 57 | /// 58 | /// It is not intended that this is accessible outside of the library itself, since we do not make guarantees around 59 | /// its lifecycle. However, to make it easier to write e.g. a mock connection class, it is made available. 60 | /// 61 | /// - Parameter connection: The connection to create using 62 | public init(connection: HAConnection) { 63 | self.connection = connection 64 | } 65 | 66 | /// Get a cache by its key 67 | /// 68 | /// - SeeAlso: `HACachesContainer` class description for how to use keys to retrieve caches. 69 | /// - Subscript: The key to look up 70 | /// - Returns: Either the existing cache for the key, or a new one created on-the-fly if none was available 71 | public subscript(_ key: KeyType.Type, data: [String: Any] = [:]) -> HACache { 72 | // ObjectIdentifier is globally unique per class _or_ meta type, and we're using meta type here 73 | let key = ObjectIdentifier(KeyType.self) 74 | 75 | if let cacheEntries = values[key], let cacheEntry = cacheEntries.first(where: { entry in 76 | // Avoid unecessary json serialization to compare dictionaries 77 | if entry.data.isEmpty, data.isEmpty { 78 | return true 79 | } 80 | 81 | let currentData = try? JSONSerialization.data(withJSONObject: entry.data, options: .prettyPrinted) 82 | let requestedData = try? JSONSerialization.data(withJSONObject: data, options: .prettyPrinted) 83 | 84 | return currentData == requestedData 85 | }), let cache = cacheEntry.cache as? HACache { 86 | return cache 87 | } 88 | 89 | let cache = KeyType.create(connection: connection, data: data) 90 | if values[key] == nil { 91 | values[key] = [.init(data: data, cache: cache)] 92 | } else { 93 | values[key]?.append(CacheEntry(data: data, cache: cache)) 94 | } 95 | return cache 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /Source/Convenience/RenderTemplate.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public extension HATypedSubscription { 4 | /// Render a template and subscribe to live changes of the template 5 | /// 6 | /// - Parameters: 7 | /// - template: The template to render 8 | /// - variables: The variables to provide to the template render on the server 9 | /// - timeout: Optional timeout for how long the template can take to render 10 | /// - Returns: A typed subscriptions that can be sent via `HAConnection` 11 | static func renderTemplate( 12 | _ template: String, 13 | variables: [String: Any] = [:], 14 | timeout: Measurement? = nil 15 | ) -> HATypedSubscription { 16 | var data: [String: Any] = [:] 17 | data["template"] = template 18 | data["variables"] = variables 19 | 20 | if let timeout = timeout { 21 | data["timeout"] = timeout.converted(to: .seconds).value 22 | } 23 | 24 | return .init(request: .init(type: .renderTemplate, data: data)) 25 | } 26 | } 27 | 28 | /// Template rendered event 29 | public struct HAResponseRenderTemplate: HADataDecodable { 30 | /// The result of the template render 31 | /// 32 | /// Note that this can be any type: number, string, boolean, dictionary, array, etc. 33 | /// Templates are rendered into native JSON types and we cannot make a client-side type-safe value here. 34 | public var result: Any 35 | /// What listeners apply to the requested template 36 | public var listeners: Listeners 37 | 38 | /// The listeners for the template render 39 | /// 40 | /// For example, listening to 'all' entities (via accessing all states) or to certain entities. 41 | /// This is potentially useful to display when configuring a template as it will provide context clues about 42 | /// the performance of a particular template. 43 | public struct Listeners: HADataDecodable { 44 | /// All states are listened to 45 | public var all: Bool 46 | /// The current time is listened to 47 | public var time: Bool 48 | /// Entities that are listened to 49 | public var entities: [String] 50 | /// Domains (e.g. `light`) that are listened to 51 | public var domains: [String] 52 | 53 | /// Create with data 54 | /// - Parameter data: The data from the server 55 | /// - Throws: If any required keys are missing 56 | public init(data: HAData) throws { 57 | self.init( 58 | all: data.decode("all", fallback: false), 59 | time: data.decode("time", fallback: false), 60 | entities: data.decode("entities", fallback: []), 61 | domains: data.decode("domains", fallback: []) 62 | ) 63 | } 64 | 65 | /// Create with information 66 | /// - Parameters: 67 | /// - all: Whether all states are listened to 68 | /// - time: Whether the current time is listened to 69 | /// - entities: Entities that are listened to 70 | /// - domains: Domains that are listened to 71 | public init( 72 | all: Bool, 73 | time: Bool, 74 | entities: [String], 75 | domains: [String] 76 | ) { 77 | self.all = all 78 | self.time = time 79 | self.entities = entities 80 | self.domains = domains 81 | } 82 | } 83 | 84 | /// Create with data 85 | /// - Parameter data: The data from the server 86 | /// - Throws: If any required keys are missing 87 | public init(data: HAData) throws { 88 | try self.init( 89 | result: data.decode("result"), 90 | listeners: data.decode("listeners") 91 | ) 92 | } 93 | 94 | /// Create with information 95 | /// - Parameters: 96 | /// - result: The result of the render template 97 | /// - listeners: The listeners of the render template 98 | public init(result: Any, listeners: Listeners) { 99 | self.result = result 100 | self.listeners = listeners 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /Tests/HAEntity+CompressedEntity.test.swift: -------------------------------------------------------------------------------- 1 | @testable import HAKit 2 | import XCTest 3 | 4 | internal final class HAEntity_CompressedEntity_test: XCTestCase { 5 | func testUpdatedEntityCompressedEntityStateUpdatesEntity() throws { 6 | var entity = try XCTUnwrap(HAEntity( 7 | entityId: "light.kitchen", 8 | domain: "light", 9 | state: "on", 10 | lastChanged: Date(), 11 | lastUpdated: Date(), 12 | attributes: [:], 13 | context: .init(id: "", userId: "", parentId: "") 14 | )) 15 | let expectedDate = Date(timeIntervalSince1970: 1_707_933_377.952297) 16 | try entity.update( 17 | from: .init( 18 | data: 19 | .init( 20 | testJsonString: 21 | """ 22 | { 23 | "s": "off", 24 | "a": { 25 | "abc": "def" 26 | }, 27 | "c": "01HPMC69D08CHCWQ76GC69BD3G", 28 | "lc": 1707933377.952297, 29 | "lu": 1707933377.952297 30 | } 31 | """ 32 | ) 33 | ) 34 | ) 35 | 36 | XCTAssertEqual(entity.state, "off") 37 | XCTAssertEqual(entity.attributes.dictionary as? [String: String], ["abc": "def"]) 38 | XCTAssertEqual(entity.context.id, "01HPMC69D08CHCWQ76GC69BD3G") 39 | XCTAssertEqual(entity.lastUpdated, expectedDate) 40 | XCTAssertEqual(entity.lastChanged, expectedDate) 41 | } 42 | 43 | func testUpdatedEntityAddingCompressedEntityStateAddsToEntity() throws { 44 | var entity = try XCTUnwrap(HAEntity( 45 | entityId: "light.kitchen", 46 | domain: "light", 47 | state: "on", 48 | lastChanged: Date(), 49 | lastUpdated: Date(), 50 | attributes: ["hello": "world"], 51 | context: .init(id: "", userId: "", parentId: "") 52 | )) 53 | let expectedDate = Date(timeIntervalSince1970: 1_707_933_377.952297) 54 | try entity.add( 55 | .init( 56 | data: 57 | .init( 58 | testJsonString: 59 | """ 60 | { 61 | "s": "off", 62 | "a": { 63 | "abc": "def" 64 | }, 65 | "c": "01HPMC69D08CHCWQ76GC69BD3G", 66 | "lc": 1707933377.952297, 67 | "lu": 1707933377.952297 68 | } 69 | """ 70 | ) 71 | ) 72 | ) 73 | 74 | XCTAssertEqual(entity.state, "off") 75 | XCTAssertEqual(entity.attributes.dictionary as? [String: String], ["hello": "world", "abc": "def"]) 76 | XCTAssertEqual(entity.context.id, "01HPMC69D08CHCWQ76GC69BD3G") 77 | XCTAssertEqual(entity.lastUpdated, expectedDate) 78 | XCTAssertEqual(entity.lastChanged, expectedDate) 79 | } 80 | 81 | func testUpdatedEntitySubtractingCompressedEntityStateSubtractFromEntity() throws { 82 | var entity = try XCTUnwrap(HAEntity( 83 | entityId: "light.kitchen", 84 | domain: "light", 85 | state: "on", 86 | lastChanged: Date(), 87 | lastUpdated: Date(), 88 | attributes: ["hello": "world", "abc": "def"], 89 | context: .init(id: "", userId: "", parentId: "") 90 | )) 91 | try entity.subtract( 92 | .init( 93 | data: 94 | .init( 95 | testJsonString: 96 | """ 97 | { 98 | "s": "off", 99 | "a": { 100 | "abc": "def" 101 | } 102 | } 103 | """ 104 | ) 105 | ) 106 | ) 107 | 108 | XCTAssertEqual(entity.state, "on") 109 | XCTAssertEqual(entity.attributes.dictionary as? [String: String], ["hello": "world", "abc": "def"]) 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /Source/Caches/HACachePopulateInfo.swift: -------------------------------------------------------------------------------- 1 | /// Information about the populate call in the cache 2 | /// 3 | /// This is issued in a few situations: 4 | /// 1. To initially populate the cache value for the first to-the-cache subscription 5 | /// 2. To update the value when reconnecting after having been disconnected 6 | /// 3. When a subscribe handler says that it needs to re-execute the populate to get a newer value 7 | public struct HACachePopulateInfo { 8 | /// Create the information for populate 9 | /// - Parameters: 10 | /// - request: The request to perform 11 | /// - transform: The handler to convert the request's result into the cache's value type 12 | public init( 13 | request: HATypedRequest, 14 | transform: @escaping (HACacheTransformInfo) -> OutgoingType 15 | ) { 16 | let retryRequest: HATypedRequest = { 17 | var updated = request 18 | updated.request.shouldRetry = true 19 | return updated 20 | }() 21 | self.init( 22 | request: request.request, 23 | anyTransform: { possibleValue in 24 | guard let value = possibleValue as? HACacheTransformInfo else { 25 | throw TransformError.incorrectType( 26 | have: String(describing: possibleValue), 27 | expected: String(describing: IncomingType.self) 28 | ) 29 | } 30 | 31 | return transform(value) 32 | }, start: { connection, perform in 33 | connection.send(retryRequest, completion: { result in 34 | perform { current in 35 | try transform(.init(incoming: result.get(), current: current)) 36 | } 37 | }) 38 | } 39 | ) 40 | } 41 | 42 | /// The untyped request that underlies the request that created this info 43 | /// - Important: This is intended to be used exclusively for writing tests; this method is not called by the cache. 44 | public let request: HARequest 45 | 46 | /// Error during transform attempt 47 | public enum TransformError: Error { 48 | /// The provided type information didn't match what this info was created with 49 | case incorrectType(have: String, expected: String) 50 | } 51 | 52 | /// Attempt to replicate the transform provided during initialization 53 | /// 54 | /// Since we erase away the incoming type, you need to provide this hinted with a type when executing this block. 55 | /// 56 | /// - Important: This is intended to be used exclusively for writing tests; this method is not called by the cache. 57 | /// - Parameters: 58 | /// - incoming: The incoming value, of some given type -- intended to be the IncomingType that created this 59 | /// - current: The current value part of the transform info 60 | /// - Throws: If the type of incoming does not match the original IncomingType 61 | /// - Returns: The transformed incoming value 62 | public func transform(incoming: IncomingType, current: OutgoingType?) throws -> OutgoingType { 63 | try anyTransform(HACacheTransformInfo(incoming: incoming, current: current)) 64 | } 65 | 66 | /// The start handler 67 | typealias StartHandler = (HAConnection, @escaping ((OutgoingType?) throws -> OutgoingType) -> Void) -> HACancellable 68 | 69 | /// Type-erasing block to perform the populate and its transform 70 | internal let start: StartHandler 71 | 72 | /// Helper to allow writing tests around the struct value 73 | internal var anyTransform: (Any) throws -> OutgoingType 74 | 75 | /// Create with a start block 76 | /// 77 | /// Only really useful in unit tests to avoid setup. 78 | /// 79 | /// - Parameters: 80 | /// - request: The untyped request this was created with 81 | /// - anyTransform: The transform to provide for testing this value 82 | /// - start: The start block 83 | internal init( 84 | request: HARequest, 85 | anyTransform: @escaping (Any) throws -> OutgoingType, 86 | start: @escaping StartHandler 87 | ) { 88 | self.request = request 89 | self.anyTransform = anyTransform 90 | self.start = start 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /Tests/HACacheSubscribeInfo.test.swift: -------------------------------------------------------------------------------- 1 | @testable import HAKit 2 | #if SWIFT_PACKAGE 3 | import HAKit_Mocks 4 | #endif 5 | import XCTest 6 | 7 | internal class HACacheSubscribeInfoTests: XCTestCase { 8 | private var connection: HAMockConnection! 9 | private var subscription: HATypedSubscription! 10 | 11 | override func setUp() { 12 | super.setUp() 13 | subscription = HATypedSubscription(request: .init(type: "test", data: ["in_data": true])) 14 | connection = HAMockConnection() 15 | } 16 | 17 | func testTryTransform() throws { 18 | var result: HACacheSubscribeInfo.Response = .ignore 19 | let item = SubscribeItem() 20 | 21 | let info = HACacheSubscribeInfo( 22 | subscription: subscription, transform: { value in 23 | XCTAssertEqual(value.current, item) 24 | return result 25 | } 26 | ) 27 | XCTAssertEqual(info.request.type, "test") 28 | XCTAssertEqual(info.request.data["in_data"] as? Bool, true) 29 | 30 | XCTAssertThrowsError(try info.transform(incoming: "hello", current: item, subscriptionPhase: .initial)) 31 | XCTAssertThrowsError(try info.transform( 32 | incoming: SubscribeItem?.none, 33 | current: item, 34 | subscriptionPhase: .iteration 35 | )) 36 | 37 | result = .reissuePopulate 38 | XCTAssertEqual(try info.transform(incoming: item, current: item, subscriptionPhase: .iteration), result) 39 | 40 | result = .replace(SubscribeItem()) 41 | XCTAssertEqual(try info.transform(incoming: item, current: item, subscriptionPhase: .iteration), result) 42 | } 43 | 44 | func testNotRetryRequest() throws { 45 | let info = HACacheSubscribeInfo(subscription: subscription, transform: { _ in .ignore }) 46 | 47 | _ = info.start(connection) { _ in } 48 | 49 | let sent = try XCTUnwrap(connection.pendingSubscriptions.first) 50 | XCTAssertTrue(subscription.request.shouldRetry) 51 | XCTAssertFalse(sent.request.shouldRetry) 52 | XCTAssertEqual(sent.request.type, "test") 53 | XCTAssertEqual(sent.request.data["in_data"] as? Bool, true) 54 | } 55 | 56 | func testSubscribeHandlerInvoked() throws { 57 | let existing = SubscribeItem() 58 | let updated = SubscribeItem() 59 | 60 | let result: HACacheSubscribeInfo.Response = .ignore 61 | 62 | let subscribeInfo = HACacheSubscribeInfo(subscription: subscription, transform: { info in 63 | XCTAssertEqual(info.incoming, updated) 64 | XCTAssertEqual(info.current.subscribeItem, existing) 65 | return result 66 | }) 67 | 68 | let invoked = expectation(description: "invoked") 69 | 70 | let token = subscribeInfo.start(connection) { handler in 71 | XCTAssertEqual(handler(.init(subscribeItem: existing)), result) 72 | invoked.fulfill() 73 | } 74 | 75 | let request = try XCTUnwrap(connection.pendingSubscriptions.get(throwing: 0)) 76 | request.handler(HACancellableImpl(handler: {}), updated.data) 77 | 78 | waitForExpectations(timeout: 10.0) 79 | 80 | XCTAssertTrue(connection.cancelledRequests.isEmpty) 81 | 82 | token.cancel() 83 | 84 | XCTAssertTrue(connection.cancelledSubscriptions.contains(where: { 85 | $0.type == subscription.request.type 86 | })) 87 | } 88 | } 89 | 90 | private class SubscribeWrapper: Equatable { 91 | static func == (lhs: SubscribeWrapper, rhs: SubscribeWrapper) -> Bool { 92 | lhs.subscribeItem == rhs.subscribeItem 93 | } 94 | 95 | let subscribeItem: SubscribeItem 96 | init(subscribeItem: SubscribeItem) { 97 | self.subscribeItem = subscribeItem 98 | } 99 | } 100 | 101 | private class SubscribeItem: HADataDecodable, Equatable { 102 | let uuid: UUID 103 | 104 | init() { 105 | self.uuid = UUID() 106 | } 107 | 108 | required init(data: HAData) throws { 109 | self.uuid = try data.decode("uuid", transform: UUID.init(uuidString:)) 110 | } 111 | 112 | var data: HAData { 113 | .dictionary(["uuid": uuid.uuidString]) 114 | } 115 | 116 | static func == (lhs: SubscribeItem, rhs: SubscribeItem) -> Bool { 117 | lhs.uuid == rhs.uuid 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /Source/Data/HADecodeTransformable.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// Decode a value by massagging into another type 4 | /// 5 | /// For example, this allows decoding a Date from a String without having to do intermediate casting in calling code. 6 | public protocol HADecodeTransformable { 7 | /// Convert some value to the expected value 8 | /// - Parameter value: The value to decode 9 | /// - Throws: If the value contained errors preventing transform 10 | /// - Returns: An instance from value -- nil allowed 11 | static func decode(unknown value: Any) throws -> Self? 12 | } 13 | 14 | // MARK: - Containers 15 | 16 | extension Optional: HADecodeTransformable where Wrapped: HADecodeTransformable { 17 | /// Transforms any transformable item into an Optional version 18 | /// - Parameter value: The value to be converted 19 | /// - Throws: If the underlying decode throws 20 | /// - Returns: An Optional-wrapped transformed value 21 | public static func decode(unknown value: Any) throws -> Self? { 22 | try Wrapped.decode(unknown: value) 23 | } 24 | } 25 | 26 | extension Array: HADecodeTransformable where Element: HADecodeTransformable { 27 | /// Transforms any array of transformable items 28 | /// - Parameter value: The array of values to convert 29 | /// - Throws: If the underlying decode throws 30 | /// - Returns: The array of values converted, compacted to remove any failures 31 | public static func decode(unknown value: Any) throws -> Self? { 32 | guard let value = value as? [Any] else { return nil } 33 | return try value.compactMap { try Element.decode(unknown: $0) } 34 | } 35 | } 36 | 37 | extension Dictionary: HADecodeTransformable where Key == String, Value: HADecodeTransformable { 38 | /// Transforms a dictionary whose values are transformable items 39 | /// - Parameter value: The dictionary with values to be transformed 40 | /// - Throws: If the underlying decode throws 41 | /// - Returns: The dictionary of values converted, compacted to remove any failures 42 | public static func decode(unknown value: Any) throws -> Self? { 43 | guard let value = value as? [String: Any] else { return nil } 44 | return try value.compactMapValues { try Value.decode(unknown: $0) } 45 | } 46 | } 47 | 48 | // MARK: - Values 49 | 50 | extension HAData: HADecodeTransformable { 51 | /// Allows HAData to be transformed from any underlying value 52 | /// - Parameter value: Any value 53 | /// - Returns: The `HAData`-wrapped version of the value 54 | public static func decode(unknown value: Any) -> Self? { 55 | Self(value: value) 56 | } 57 | } 58 | 59 | extension Date: HADecodeTransformable { 60 | /// Date formatters 61 | private static let formatters: [ISO8601DateFormatter] = [ 62 | { 63 | let formatter = ISO8601DateFormatter() 64 | formatter.formatOptions = [.withFractionalSeconds, .withInternetDateTime] 65 | return formatter 66 | }(), 67 | { 68 | let formatter = ISO8601DateFormatter() 69 | // if python has `0` as the milliseconds value, it omits the field entirely 70 | formatter.formatOptions = [.withInternetDateTime] 71 | return formatter 72 | }(), 73 | ] 74 | 75 | /// Converts from ISO 8601 (with or without milliseconds) String to Date 76 | /// - Parameter value: A string value to convert 77 | /// - Returns: The value converted to a Date, or nil if not possible 78 | public static func decode(unknown value: Any) -> Self? { 79 | if let timestamp = value as? Double { 80 | return Date(timeIntervalSince1970: timestamp) 81 | } 82 | 83 | if let value = value as? String { 84 | for formatter in Self.formatters { 85 | if let string = formatter.date(from: value) { 86 | return string 87 | } 88 | } 89 | } 90 | 91 | return nil 92 | } 93 | } 94 | 95 | public extension HADecodeTransformable where Self: RawRepresentable { 96 | /// Converts from an unknown value to this raw representable type 97 | /// - Parameter value: The value to attempt to convert 98 | /// - Returns: The value transformed, if it is of the right type and init succeeds 99 | static func decode(unknown value: Any) -> Self? { 100 | guard let value = value as? RawValue else { return nil } 101 | return Self(rawValue: value) 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /Tests/HAWebSocketResponse.test.swift: -------------------------------------------------------------------------------- 1 | @testable import HAKit 2 | import XCTest 3 | 4 | internal class HAWebSocketResponseTests: XCTestCase { 5 | func testAuthRequired() throws { 6 | let response = try HAWebSocketResponse(dictionary: HAWebSocketResponseFixture.authRequired) 7 | XCTAssertEqual(response, .auth(.required)) 8 | } 9 | 10 | func testAuthOK() throws { 11 | let response = try HAWebSocketResponse(dictionary: HAWebSocketResponseFixture.authOK) 12 | XCTAssertEqual(response, .auth(.ok(version: "2021.3.0.dev0"))) 13 | } 14 | 15 | func testAuthOKMissingVersion() throws { 16 | let response = try HAWebSocketResponse(dictionary: HAWebSocketResponseFixture.authOKMissingVersion) 17 | XCTAssertEqual(response, .auth(.ok(version: "unknown"))) 18 | } 19 | 20 | func testAuthInvalid() throws { 21 | let response = try HAWebSocketResponse(dictionary: HAWebSocketResponseFixture.authInvalid) 22 | XCTAssertEqual(response, .auth(.invalid)) 23 | } 24 | 25 | func testResponseEmpty() throws { 26 | let response = try HAWebSocketResponse(dictionary: HAWebSocketResponseFixture.responseEmptyResult) 27 | XCTAssertEqual(response, .result(identifier: 1, result: .success(.empty))) 28 | } 29 | 30 | func testResponseDictionary() throws { 31 | let response = try HAWebSocketResponse(dictionary: HAWebSocketResponseFixture.responseDictionaryResult) 32 | XCTAssertEqual(response, .result( 33 | identifier: 2, 34 | result: .success(.dictionary(["id": "76ce52a813c44fdf80ee36f926d62328"])) 35 | )) 36 | } 37 | 38 | func testResponseEvent() throws { 39 | let response = try HAWebSocketResponse(dictionary: HAWebSocketResponseFixture.responseEvent) 40 | XCTAssertEqual(response, .event( 41 | identifier: 5, 42 | data: .dictionary(["result": "ok"]) 43 | )) 44 | } 45 | 46 | func testResponseError() throws { 47 | let response = try HAWebSocketResponse(dictionary: HAWebSocketResponseFixture.responseError) 48 | XCTAssertEqual(response, .result(identifier: 4, result: .failure(.external(.init( 49 | code: "unknown_command", 50 | message: "Unknown command." 51 | ))))) 52 | } 53 | 54 | func testResponseArray() throws { 55 | let response = try HAWebSocketResponse(dictionary: HAWebSocketResponseFixture.responseArrayResult) 56 | XCTAssertEqual(response, .result( 57 | identifier: 3, 58 | result: .success(.array([ 59 | .init(value: ["1": true]), 60 | .init(value: ["2": true]), 61 | .init(value: ["3": true]), 62 | ])) 63 | )) 64 | } 65 | 66 | func testMissingID() throws { 67 | XCTAssertThrowsError(try HAWebSocketResponse( 68 | dictionary: HAWebSocketResponseFixture 69 | .responseMissingID 70 | )) { error in 71 | switch error as? HAWebSocketResponse.ParseError { 72 | case .unknownId: break // pass 73 | default: XCTFail("expected different error") 74 | } 75 | } 76 | } 77 | 78 | func testInvalidID() throws { 79 | XCTAssertThrowsError(try HAWebSocketResponse( 80 | dictionary: HAWebSocketResponseFixture 81 | .responseInvalidID 82 | )) { error in 83 | switch error as? HAWebSocketResponse.ParseError { 84 | case .unknownId: break // pass 85 | default: XCTFail("expected different error") 86 | } 87 | } 88 | } 89 | 90 | func testMissingType() throws { 91 | XCTAssertThrowsError(try HAWebSocketResponse( 92 | dictionary: HAWebSocketResponseFixture 93 | .responseMissingType 94 | )) { error in 95 | switch error as? HAWebSocketResponse.ParseError { 96 | case .unknownType: break // pass 97 | default: XCTFail("expected different error") 98 | } 99 | } 100 | } 101 | 102 | func testInvalidType() throws { 103 | XCTAssertThrowsError(try HAWebSocketResponse( 104 | dictionary: HAWebSocketResponseFixture 105 | .responseInvalidType 106 | )) { error in 107 | switch error as? HAWebSocketResponse.ParseError { 108 | case .unknownType: break // pass 109 | default: XCTFail("expected different error") 110 | } 111 | } 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /Source/Convenience/CurrentUser.swift: -------------------------------------------------------------------------------- 1 | public extension HATypedRequest { 2 | /// Retrieve the current user 3 | /// 4 | /// - Returns: A typed request that can be sent via `HAConnection` 5 | static func currentUser() -> HATypedRequest { 6 | .init(request: .init(type: .currentUser, data: [:])) 7 | } 8 | } 9 | 10 | /// The current user 11 | public struct HAResponseCurrentUser: HADataDecodable { 12 | /// The ID of the user; this is a long hex string 13 | public var id: String 14 | /// The name of the user, if one is set 15 | public var name: String? 16 | /// Whether the user is an owner 17 | public var isOwner: Bool 18 | /// Whether the user is an admin 19 | /// 20 | /// Admins have access to a different set of commands; you may need to handle failures for commands which 21 | /// are not allowed to be executed by non-admins. 22 | public var isAdmin: Bool 23 | /// Which credentials apply to this user 24 | public var credentials: [Credential] 25 | /// Which MFA modules are available, which may include those not enabled 26 | public var mfaModules: [MFAModule] 27 | 28 | /// A credential authentication provider 29 | public struct Credential: HADataDecodable { 30 | /// The type of the credential, for example homeassistant 31 | public var type: String 32 | /// The id of the credential, specific to that credential 33 | public var id: String? 34 | 35 | /// Create with data 36 | /// - Parameter data: The data from the server 37 | /// - Throws: If any required keys are missing 38 | public init(data: HAData) throws { 39 | try self.init( 40 | type: data.decode("auth_provider_type"), 41 | id: data.decode("auth_provider_id", fallback: nil) 42 | ) 43 | } 44 | 45 | /// Create with a given type and id 46 | /// - Parameters: 47 | /// - type: The type of the credential 48 | /// - id: The id of the credential 49 | public init(type: String, id: String?) { 50 | self.type = type 51 | self.id = id 52 | } 53 | } 54 | 55 | /// An MFA module 56 | public struct MFAModule: HADataDecodable { 57 | /// The id of the module, for example `totp` 58 | public var id: String 59 | /// The name of the module, for example `Authenticator app` 60 | public var name: String 61 | /// Whether the given module is enabled for the user 62 | public var isEnabled: Bool 63 | 64 | /// Create with data 65 | /// - Parameter data: The data from the server 66 | /// - Throws: If any required keys are missing 67 | public init(data: HAData) throws { 68 | try self.init( 69 | id: data.decode("id"), 70 | name: data.decode("name"), 71 | isEnabled: data.decode("enabled") 72 | ) 73 | } 74 | 75 | /// Create with given information 76 | /// - Parameters: 77 | /// - id: The id of the module 78 | /// - name: The name of the module 79 | /// - isEnabled: Whether the module is enabled for the user 80 | public init(id: String, name: String, isEnabled: Bool) { 81 | self.id = id 82 | self.name = name 83 | self.isEnabled = isEnabled 84 | } 85 | } 86 | 87 | /// Create with data 88 | /// - Parameter data: The data from the server 89 | /// - Throws: If any required keys are missing 90 | public init(data: HAData) throws { 91 | try self.init( 92 | id: data.decode("id"), 93 | name: data.decode("name", fallback: nil), 94 | isOwner: data.decode("is_owner", fallback: false), 95 | isAdmin: data.decode("is_admin", fallback: false), 96 | credentials: data.decode("credentials", fallback: []), 97 | mfaModules: data.decode("mfa_modules", fallback: []) 98 | ) 99 | } 100 | 101 | /// Create with information 102 | /// - Parameters: 103 | /// - id: The id of the user 104 | /// - name: The name of the user 105 | /// - isOwner: Whether the user is an owner 106 | /// - isAdmin: Whether the user is an admin 107 | /// - credentials: Credentials for the user 108 | /// - mfaModules: MFA Modules for the user 109 | public init( 110 | id: String, 111 | name: String?, 112 | isOwner: Bool, 113 | isAdmin: Bool, 114 | credentials: [Credential], 115 | mfaModules: [MFAModule] 116 | ) { 117 | self.id = id 118 | self.name = name 119 | self.isOwner = isOwner 120 | self.isAdmin = isAdmin 121 | self.credentials = credentials 122 | self.mfaModules = mfaModules 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 5 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 6 | 7 | ## [0.4.8] - 2025-11-26 8 | - Changed: Prevent connection retry after invalid credentials. 9 | 10 | ## [0.4.7] - 2025-11-10 11 | - Changed: Update get_services to handle optional name and description. 12 | 13 | ## [0.4.6] - 2025-11-10 14 | - Changed: Add timeout to retry logic when performing a request. 15 | 16 | ## [0.4.5] - 2025-09-09 17 | - Fixed: Standard-compliant parsing of Content-Type header, 'allowing application/json; charset=utf-8' and variations. 18 | 19 | ## [0.4.4] - 2024-12-05 20 | - Changed: Differentiate cache also by data provided (e.g. domain filter for states) 21 | 22 | ## [0.4.3] - 2024-12-03 23 | - Changed: Allow passing data to entities state subcription, so you can filter what you want to receive 24 | 25 | ## [0.4.2] - 2024-04-22 26 | - Changed: Avoid JSON cache by JSONSerialization NSString 27 | 28 | ## [0.4.1] - 2024-04-03 29 | - Added: Typed request to send Assist audio data to Assist pipeline 30 | - Changed: Use of forked StarScream which fixes usage of URLSession 31 | 32 | ## [0.4] - 2024-03-06 33 | - Added: REST API calls can now be issued. 34 | * Added: `HAConnectionInfo` can now provide a closure to handle SecTrust (TLS certificate) validation other than the default. 35 | - Changed: `HARequestType` is now an enum of `webSocket` and `rest`. The command value for REST calls is the value after 'api/', e.g. 'api/template' has a type of `.rest(.post, "template")`. 36 | - Changed: `HAData` now includes a `primitive` case to express non-array/dictionary values that aren't `null`. 37 | - Changed: WebSocket connection will now enable compression. 38 | - Fixed: Calling `HAConnection.connect()` and `HAConnection.disconnect()` off the main thread no longer occasionally crashes. 39 | - Removed: Usage of "get_states" 40 | - Added: More efficient API "subscribe_entities" replacing "get_states" 41 | 42 | ## [0.3] - 2021-07-08 43 | - Added: Subscriptions will now retry (when their request `shouldRetry`) when the HA config changes or components are loaded. 44 | - Changed: `HAConnectionInfo` now has a throwing initializer. See `HAConnectionInfo.CreationError` for details. 45 | 46 | ## [0.2.2] - 2021-05-01 47 | - Added: Allow overriding `User-Agent` header in connection via `HAConnectionInfo`. 48 | - Fixed: `Host` header now properly excludes port so we match URLSession behavior. 49 | - Fixed: Services now load successfully for versions of HA Core prior to 2021.3 when `name` was added. 50 | 51 | ## [0.2.1] - 2021-04-05 52 | - Changed: `HAGlobal`'s `log` block now contains a log level, either `info` or `error`. 53 | - Fixed: Failed populate requests no longer crash when a later subscription is updated. 54 | - Fixed: The error log from a failed `HACache` populate now contains more information. 55 | - Fixed: Dates from HA which lack milliseconds no longer fail to parse. 56 | 57 | ## [0.2.0] - 2021-04-04 58 | - Added: `HACache` which can send requests and subscribe to events to keep its value up-to-date. 59 | - Added `HACachesContainer` accessible as `connection.caches` which contains built-in caches. 60 | - Added: `connection.caches.states` which contains and keeps up-to-date all entity states. 61 | - Added: `connection.caches.user` which contains the current user. 62 | - Added: Optional `PromiseKit` target/subspec. 63 | - Added: Optional `HAMockConnection` target/subspec for use in test cases. 64 | - Added: `connectAutomatically` parameter to connection creation. This will call `connect()` when requests are sent if not connected. 65 | - Added: `.getServices()` typed request. 66 | - Added: `.getStates()` typed request. 67 | - Changed: Swapped to using the custom (not URLSession) engine in Starscream to try and figure out if URLSession is causing connectivity issues. 68 | - Changed: `attributes` and `context` on `HAEntity` are now represented by parsed types. 69 | - Changed: Many internal cases of JSON parsing and decoding are now done off the main thread. 70 | - Changed: Events to unknown subscriptions (that is, a logic error in the library somewhere) no longer unsubscribe as this was sending erroneously during reconnects. 71 | - Fixed: Calling `connect()` when already connected no longer disconnects and reconnects. 72 | - Fixed: Calling `cancel()` on a subscription more than once or on a non-retried subscription sends multiple unsubscribe requests. 73 | - Fixed: Disconnections silently occurred due to e.g. suspension; pings are now sent regularly to make sure the connection really is active. 74 | 75 | ## [0.1.0] - 2021-03-05 76 | Initial release. 77 | 78 | 88 | -------------------------------------------------------------------------------- /.swiftpm/xcode/xcshareddata/xcschemes/HAKit.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 29 | 35 | 36 | 37 | 43 | 49 | 50 | 51 | 57 | 63 | 64 | 65 | 66 | 67 | 74 | 75 | 77 | 83 | 84 | 85 | 86 | 87 | 98 | 99 | 105 | 106 | 112 | 113 | 114 | 115 | 117 | 118 | 121 | 122 | 123 | -------------------------------------------------------------------------------- /Source/Data/HADataDecodable.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// A type which can be decoded using our data type 4 | /// 5 | /// - Note: This differs from `Decodable` intentionally; `Decodable` does not support `Any` types or JSON well when the 6 | /// results are extremely dynamic. This limitation requires that we do it ourselves. 7 | public protocol HADataDecodable: HADecodeTransformable { 8 | /// Create an instance from data 9 | /// One day, if Decodable can handle 'Any' types well, this can be init(decoder:). 10 | /// 11 | /// - Parameter data: The data to decode 12 | /// - Throws: When unable to decode 13 | init(data: HAData) throws 14 | } 15 | 16 | public extension HADataDecodable { 17 | /// Create a `HADataDecodable` instance via `.decode(…)` indirection 18 | /// - Parameter value: The value to convert to HAData for the init 19 | /// - Throws: When unable to decode 20 | /// - Returns: The decodable initialized with the given value 21 | static func decode(unknown value: Any) throws -> Self? { 22 | try .init(data: HAData(value: value)) 23 | } 24 | } 25 | 26 | extension Array: HADataDecodable where Element: HADataDecodable { 27 | /// Construct an array of decodable elements 28 | /// - Parameter data: The data to decode 29 | /// - Throws: When unable to decode, e.g. the data isn't an array 30 | public init(data: HAData) throws { 31 | guard case let .array(array) = data else { 32 | throw HADataError.couldntTransform(key: "root") 33 | } 34 | 35 | try self.init(array.map { try Element(data: $0) }) 36 | } 37 | } 38 | 39 | /// Parse error 40 | public enum HADataError: Error, Equatable { 41 | /// The given key was missing 42 | case missingKey(String) 43 | /// The given key was present but the type could not be converted 44 | case incorrectType(key: String, expected: String, actual: String) 45 | /// The given key was present but couldn't be converted 46 | case couldntTransform(key: String) 47 | } 48 | 49 | public extension HAData { 50 | /// Convenience access to the dictionary case for a particular key, with an expected type 51 | /// 52 | /// - Parameter key: The key to look up in `dictionary` case 53 | /// - Throws: If the key was not present in the dictionary or the type was not the expected type or convertable 54 | /// - Returns: The value from the dictionary 55 | func decode(_ key: String) throws -> T { 56 | guard case let .dictionary(dictionary) = self, let value = dictionary[key] else { 57 | throw HADataError.missingKey(key) 58 | } 59 | 60 | // Not the prettiest, but we need to super duper promise to the compiler that we're returning a good value 61 | if let type = T.self as? HADecodeTransformable.Type, let inside = try type.decode(unknown: value) as? T { 62 | return inside 63 | } 64 | 65 | if let value = value as? T { 66 | // Avoid full JSON cache when using JSONSerialization and referencing NSString 67 | if var valueString = value as? String { 68 | valueString.makeContiguousUTF8() 69 | return valueString as? T ?? value 70 | } else { 71 | return value 72 | } 73 | } 74 | 75 | throw HADataError.incorrectType( 76 | key: key, 77 | expected: String(describing: T.self), 78 | actual: String(describing: type(of: value)) 79 | ) 80 | } 81 | 82 | /// Convenience access to the dictionary case for a particular key, with an expected type, with a transform applied 83 | /// 84 | /// - Parameters: 85 | /// - key: The key to look up in `dictionary` case 86 | /// - transform: The transform to apply to the value, when found 87 | /// - Throws: If the key was not present in the dictionary or the type was not the expected type or the value 88 | /// couldn't be transformed 89 | /// - Returns: The value from the dictionary 90 | func decode(_ key: String, transform: (Value) throws -> Transform?) throws -> Transform { 91 | let base: Value = try decode(key) 92 | 93 | guard let transformed = try transform(base) else { 94 | throw HADataError.couldntTransform(key: key) 95 | } 96 | 97 | return transformed 98 | } 99 | 100 | /// Convenience access to the dictionary case for a particular key, with an expected type 101 | /// 102 | /// - Parameters: 103 | /// - key: The key to look up in `dictionary` case 104 | /// - fallback: The fallback value to use if not found in the dictionary 105 | /// - Throws: If the inner fallback block throws 106 | /// - Returns: The value from the dictionary 107 | func decode(_ key: String, fallback: @autoclosure () throws -> T) rethrows -> T { 108 | guard let value: T = try? decode(key) else { 109 | return try fallback() 110 | } 111 | 112 | return value 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /Tests/HACachePopulateInfo.test.swift: -------------------------------------------------------------------------------- 1 | @testable import HAKit 2 | #if SWIFT_PACKAGE 3 | import HAKit_Mocks 4 | #endif 5 | import XCTest 6 | 7 | internal class HACachePopulateInfoTests: XCTestCase { 8 | private var connection: HAMockConnection! 9 | private var request: HATypedRequest! 10 | 11 | override func setUp() { 12 | super.setUp() 13 | request = HATypedRequest(request: .init(type: "test", data: ["in_data": true])) 14 | connection = HAMockConnection() 15 | } 16 | 17 | func testTryTransform() throws { 18 | let info = HACachePopulateInfo(request: request, transform: \.incoming) 19 | XCTAssertEqual(info.request.type, "test") 20 | XCTAssertEqual(info.request.data["in_data"] as? Bool, true) 21 | 22 | XCTAssertThrowsError(try info.transform(incoming: "hello", current: nil)) 23 | XCTAssertThrowsError(try info.transform(incoming: PopulateItem?.none, current: nil)) 24 | 25 | let item = PopulateItem() 26 | XCTAssertEqual(try info.transform(incoming: item, current: item), item) 27 | XCTAssertEqual(try info.transform(incoming: item, current: nil), item) 28 | } 29 | 30 | func testShouldRetryRequest() throws { 31 | request.request.shouldRetry = false 32 | let info = HACachePopulateInfo(request: request, transform: \.incoming) 33 | 34 | _ = info.start(connection) { _ in } 35 | 36 | let sent = try XCTUnwrap(connection.pendingRequests.first) 37 | XCTAssertTrue(sent.request.shouldRetry) 38 | XCTAssertEqual(sent.request.type, "test") 39 | XCTAssertEqual(sent.request.data["in_data"] as? Bool, true) 40 | } 41 | 42 | func testSendSucceeds() throws { 43 | let existing = PopulateItem() 44 | let updated = PopulateItem() 45 | 46 | let populateInfo: HACachePopulateInfo = HACachePopulateInfo( 47 | request: request, 48 | transform: { info in 49 | XCTAssertEqual(info.incoming, updated) 50 | XCTAssertEqual(info.current?.populateItem, existing) 51 | return PopulateWrapper(populateItem: info.incoming) 52 | } 53 | ) 54 | 55 | let invoked = expectation(description: "invoked") 56 | 57 | _ = populateInfo.start(connection) { handler in 58 | XCTAssertEqual(try? handler(.init(populateItem: existing)).populateItem, updated) 59 | invoked.fulfill() 60 | } 61 | 62 | let request = try XCTUnwrap(connection.pendingRequests.get(throwing: 0)) 63 | request.completion(.success(updated.data)) 64 | 65 | waitForExpectations(timeout: 10.0) 66 | 67 | XCTAssertTrue(connection.cancelledRequests.isEmpty) 68 | } 69 | 70 | func testSendFails() throws { 71 | let populateInfo = HACachePopulateInfo(request: request, transform: { info in 72 | XCTFail("should not have invoked request transform") 73 | return PopulateWrapper(populateItem: info.incoming) 74 | }) 75 | 76 | _ = populateInfo.start(connection) { perform in 77 | do { 78 | XCTAssertThrowsError(try perform(nil)) 79 | _ = try perform(nil) // added just for the compiler 80 | } catch { 81 | // this fixes a false-positive in compiler where it thinks this function throws 82 | } 83 | } 84 | 85 | let request = try XCTUnwrap(connection.pendingRequests.get(throwing: 0)) 86 | request.completion(.failure(.internal(debugDescription: "unit-test"))) 87 | 88 | XCTAssertTrue(connection.cancelledRequests.isEmpty) 89 | } 90 | 91 | func testSendCancelled() { 92 | let populateInfo = HACachePopulateInfo(request: request, transform: { info in 93 | XCTFail("should not have invoked request transform") 94 | return PopulateWrapper(populateItem: info.incoming) 95 | }) 96 | 97 | let token = populateInfo.start(connection) { _ in 98 | XCTFail("should not have invoked") 99 | } 100 | 101 | token.cancel() 102 | XCTAssertTrue(connection.cancelledRequests.contains(where: { possible in 103 | possible.type == request.request.type 104 | })) 105 | } 106 | } 107 | 108 | private class PopulateWrapper { 109 | let populateItem: PopulateItem 110 | init(populateItem: PopulateItem) { 111 | self.populateItem = populateItem 112 | } 113 | } 114 | 115 | private class PopulateItem: HADataDecodable, Equatable { 116 | let uuid: UUID 117 | 118 | init() { 119 | self.uuid = UUID() 120 | } 121 | 122 | required init(data: HAData) throws { 123 | self.uuid = try data.decode("uuid", transform: UUID.init(uuidString:)) 124 | } 125 | 126 | var data: HAData { 127 | .dictionary(["uuid": uuid.uuidString]) 128 | } 129 | 130 | static func == (lhs: PopulateItem, rhs: PopulateItem) -> Bool { 131 | lhs.uuid == rhs.uuid 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /Source/Caches/HACacheSubscribeInfo.swift: -------------------------------------------------------------------------------- 1 | /// Information about the subscriptions used to keep the cache up-to-date 2 | public struct HACacheSubscribeInfo { 3 | /// The response to a subscription event 4 | public enum Response { 5 | /// Does not require any changes 6 | case ignore 7 | /// Issue the populate call again to get a newer value 8 | case reissuePopulate 9 | /// Replace the current cache value with this new one 10 | case replace(OutgoingType) 11 | } 12 | 13 | /// Create the information for subscription 14 | /// - Parameters: 15 | /// - subscription: The subscription to perform after populate completes 16 | /// - transform: The handler to convert the subscription's handler type into the cache's value 17 | public init( 18 | subscription: HATypedSubscription, 19 | transform: @escaping (HACacheTransformInfo) -> Response 20 | ) { 21 | let nonRetrySubscription: HATypedSubscription = { 22 | var updated = subscription 23 | updated.request.shouldRetry = false 24 | return updated 25 | }() 26 | 27 | self.init( 28 | request: subscription.request, 29 | anyTransform: { possibleValue in 30 | guard let value = possibleValue as? HACacheTransformInfo else { 31 | throw TransformError.incorrectType( 32 | have: String(describing: possibleValue), 33 | expected: String(describing: IncomingType.self) 34 | ) 35 | } 36 | 37 | return transform(value) 38 | }, start: { connection, perform in 39 | var operationType: HACacheSubscriptionPhase = .initial 40 | return connection.subscribe(to: nonRetrySubscription, handler: { _, incoming in 41 | perform { current in 42 | let transform = transform(.init( 43 | incoming: incoming, 44 | current: current, 45 | subscriptionPhase: operationType 46 | )) 47 | operationType = .iteration 48 | return transform 49 | } 50 | }) 51 | } 52 | ) 53 | } 54 | 55 | /// The untyped request that underlies the subscription that created this info 56 | /// - Important: This is intended to be used exclusively for writing tests; this method is not called by the cache. 57 | public let request: HARequest 58 | 59 | /// Error during transform attempt 60 | public enum TransformError: Error { 61 | /// The provided type information didn't match what this info was created with 62 | case incorrectType(have: String, expected: String) 63 | } 64 | 65 | /// Attempt to replicate the transform provided during initialization 66 | /// 67 | /// Since we erase away the incoming type, you need to provide this hinted with a type when executing this block. 68 | /// 69 | /// - Important: This is intended to be used exclusively for writing tests; this method is not called by the cache. 70 | /// - Parameters: 71 | /// - incoming: The incoming value, of some given type -- intended to be the IncomingType that created this 72 | /// - current: The current value part of the transform info 73 | /// - subscriptionPhase: The phase in which the subscription is, initial iteration or subsequent 74 | /// - Throws: If the type of incoming does not match the original IncomingType 75 | /// - Returns: The response from the transform block 76 | public func transform( 77 | incoming: IncomingType, 78 | current: OutgoingType, 79 | subscriptionPhase: HACacheSubscriptionPhase 80 | ) throws -> Response { 81 | try anyTransform(HACacheTransformInfo( 82 | incoming: incoming, 83 | current: current, 84 | subscriptionPhase: subscriptionPhase 85 | )) 86 | } 87 | 88 | /// The start handler 89 | typealias StartHandler = (HAConnection, @escaping ((OutgoingType) -> Response) -> Void) -> HACancellable 90 | 91 | /// Type-erasing block to perform the subscription and its transform 92 | internal let start: StartHandler 93 | 94 | /// Helper to allow writing tests around the struct value 95 | internal var anyTransform: (Any) throws -> Response 96 | 97 | /// Create with a start block 98 | /// 99 | /// Only really useful in unit tests to avoid setup. 100 | /// 101 | /// - Parameters: 102 | /// - request: The request that is tied to the subscription 103 | /// - anyTransform: The transform to provide for testing this value 104 | /// - start: The start block 105 | internal init(request: HARequest, anyTransform: @escaping (Any) throws -> Response, start: @escaping StartHandler) { 106 | self.request = request 107 | self.start = start 108 | self.anyTransform = anyTransform 109 | } 110 | } 111 | 112 | extension HACacheSubscribeInfo.Response: Equatable where OutgoingType: Equatable {} 113 | -------------------------------------------------------------------------------- /Source/Convenience/Services.swift: -------------------------------------------------------------------------------- 1 | public extension HATypedRequest { 2 | /// Call a service 3 | /// 4 | /// - Note: Parameters are `ExpressibleByStringLiteral`. You can use strings instead of using `.init(rawValue:)`. 5 | /// - Parameters: 6 | /// - domain: The domain of the service, e.g. `light` 7 | /// - service: The service, e.g. `turn_on` 8 | /// - data: The service data 9 | /// - Returns: A typed request that can be sent via `HAConnection` 10 | static func callService( 11 | domain: HAServicesDomain, 12 | service: HAServicesService, 13 | data: [String: Any] = [:] 14 | ) -> HATypedRequest { 15 | .init(request: .init(type: .callService, data: [ 16 | "domain": domain.rawValue, 17 | "service": service.rawValue, 18 | "service_data": data, 19 | ])) 20 | } 21 | 22 | /// Retrieve definition of all services 23 | /// 24 | /// - Returns: A typed request that can be sent via `HAConnection` 25 | static func getServices() -> HATypedRequest { 26 | .init(request: .init(type: .getServices, data: [:])) 27 | } 28 | } 29 | 30 | /// A service definition 31 | public struct HAServiceDefinition { 32 | /// The domain of the service, for example `light` in `light.turn_on` 33 | public var domain: HAServicesDomain 34 | /// The service, for example `turn_on` in `light.turn_on` 35 | public var service: HAServicesService 36 | /// The pair of domain and service, for example `light.turn_on` 37 | public var domainServicePair: String 38 | 39 | /// The name of the service, for example "Turn On" 40 | public var name: String? 41 | /// The description of the service 42 | public var description: String? 43 | /// Available fields of the service call 44 | public var fields: [String: [String: Any]] 45 | 46 | /// Create with information 47 | /// - Parameters: 48 | /// - domain: The domain 49 | /// - service: The service 50 | /// - data: The data for the definition 51 | /// - Throws: If any required keys are missing in the data 52 | public init(domain: HAServicesDomain, service: HAServicesService, data: HAData) throws { 53 | var nameValue: String? = try? data.decode("name") 54 | var descriptionValue: String? = try? data.decode("description") 55 | 56 | // Treat empty strings as nil 57 | if nameValue?.isEmpty == true { nameValue = nil } 58 | if descriptionValue?.isEmpty == true { descriptionValue = nil } 59 | 60 | try self.init( 61 | domain: domain, 62 | service: service, 63 | name: nameValue ?? descriptionValue, 64 | description: descriptionValue, 65 | fields: data.decode("fields") 66 | ) 67 | } 68 | 69 | /// Create with information 70 | /// - Parameters: 71 | /// - domain: The domain of the service 72 | /// - service: The service 73 | /// - name: The friendly name of the service 74 | /// - description: The description of the service 75 | /// - fields: Available fields in the service call 76 | public init( 77 | domain: HAServicesDomain, 78 | service: HAServicesService, 79 | name: String?, 80 | description: String?, 81 | fields: [String: [String: Any]] 82 | ) { 83 | self.domain = domain 84 | self.service = service 85 | let domainServicePair = "\(domain.rawValue).\(service.rawValue)" 86 | self.domainServicePair = domainServicePair 87 | self.name = name ?? domainServicePair 88 | self.description = description 89 | self.fields = fields 90 | } 91 | } 92 | 93 | /// The services available 94 | public struct HAResponseServices: HADataDecodable { 95 | /// Create with data 96 | /// - Parameter data: The data from the server 97 | /// - Throws: If any required keys are missing 98 | public init(data: HAData) throws { 99 | guard case let .dictionary(rawDictionary) = data, 100 | let dictionary = rawDictionary as? [String: [String: [String: Any]]] else { 101 | throw HADataError.couldntTransform(key: "get_services_root") 102 | } 103 | 104 | self.allByDomain = try dictionary.reduce(into: [:]) { domains, domainEntry in 105 | let domain = HAServicesDomain(rawValue: domainEntry.key) 106 | domains[domain] = try domainEntry.value.reduce( 107 | into: [HAServicesService: HAServiceDefinition]() 108 | ) { services, serviceEntry in 109 | let service = HAServicesService(rawValue: serviceEntry.key) 110 | services[service] = try HAServiceDefinition( 111 | domain: domain, 112 | service: service, 113 | data: .dictionary(serviceEntry.value) 114 | ) 115 | } 116 | } 117 | } 118 | 119 | /// All service definitions, divided by domain and then by service 120 | /// For example, you can access allByDomain["light"]["turn_on"] for the definition of `light.turn_on` 121 | public var allByDomain: [HAServicesDomain: [HAServicesService: HAServiceDefinition]] 122 | /// All service definitions, sorted by their `\.domainServicePair` 123 | public var all: [HAServiceDefinition] { 124 | allByDomain.values.flatMap(\.values).sorted(by: { a, b in 125 | a.domainServicePair < b.domainServicePair 126 | }) 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /Source/HAConnectionInfo.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Starscream 3 | 4 | /// Information for connecting to the server 5 | public struct HAConnectionInfo: Equatable { 6 | /// Thrown if connection info was not able to be created 7 | enum CreationError: Error { 8 | /// The URL's host was empty, which would otherwise crash if used 9 | case emptyHostname 10 | /// The port provided exceeds the maximum allowed TCP port (2^16-1) 11 | case invalidPort 12 | } 13 | 14 | /// Certificate validation handler 15 | public typealias EvaluateCertificate = (SecTrust, (Result) -> Void) -> Void 16 | 17 | /// Create a connection info 18 | /// 19 | /// URLs are in the form of: https://url-to-hass:8123 and /api/websocket will be appended. 20 | /// 21 | /// - Parameter url: The url to connect to 22 | /// - Parameter userAgent: Optionally change the User-Agent to this 23 | /// - Parameter evaluateCertificate: Optionally override default SecTrust validation 24 | /// - Throws: If the URL provided is invalid in some way, see `CreationError` 25 | public init(url: URL, userAgent: String? = nil, evaluateCertificate: EvaluateCertificate? = nil) throws { 26 | try self.init(url: url, userAgent: userAgent, evaluateCertificate: evaluateCertificate, engine: nil) 27 | } 28 | 29 | /// Internally create a connection info with engine 30 | internal init(url: URL, userAgent: String?, evaluateCertificate: EvaluateCertificate?, engine: Engine?) throws { 31 | guard let host = url.host, !host.isEmpty else { 32 | throw CreationError.emptyHostname 33 | } 34 | 35 | guard (url.port ?? 80) <= UInt16.max else { 36 | throw CreationError.invalidPort 37 | } 38 | 39 | self.url = Self.sanitize(url) 40 | self.userAgent = userAgent 41 | self.engine = engine 42 | self.evaluateCertificate = evaluateCertificate 43 | } 44 | 45 | /// The base URL for the WebSocket connection 46 | public var url: URL 47 | /// The URL used to connect to the WebSocket API 48 | public var webSocketURL: URL { 49 | url.appendingPathComponent("api/websocket") 50 | } 51 | 52 | /// The user agent to use in the connection 53 | public var userAgent: String? 54 | 55 | /// Used for dependency injection in tests 56 | internal var engine: Engine? 57 | 58 | /// Used to validate certificate, if provided 59 | internal var evaluateCertificate: EvaluateCertificate? 60 | 61 | /// Should this connection info take over an existing connection? 62 | /// 63 | /// - Parameter webSocket: The WebSocket to test 64 | /// - Returns: true if the connection should be replaced, false otherwise 65 | internal func shouldReplace(_ webSocket: WebSocket) -> Bool { 66 | webSocket.request.url.map(Self.sanitize) != Self.sanitize(url) 67 | } 68 | 69 | internal func request(url: URL) -> URLRequest { 70 | var request = URLRequest(url: url) 71 | 72 | if let userAgent = userAgent { 73 | request.setValue(userAgent, forHTTPHeaderField: "User-Agent") 74 | } 75 | 76 | if let host = url.host { 77 | if let port = url.port, port != 80, port != 443 { 78 | // URLSession behavior: omit optional port if 80 or 443 79 | // Starscream does the opposite, where it always includes port 80 | request.setValue("\(host):\(port)", forHTTPHeaderField: "Host") 81 | } else { 82 | request.setValue(host, forHTTPHeaderField: "Host") 83 | } 84 | } 85 | 86 | return request 87 | } 88 | 89 | /// Create a URLRequest for a given REST API path and query items 90 | /// - Parameters: 91 | /// - path: The path to invoke, including the 'api/' part. 92 | /// - queryItems: The query items to include in the URL 93 | /// - Returns: The URLRequest for the given parameters 94 | internal func request( 95 | path: String, 96 | queryItems: [URLQueryItem] 97 | ) -> URLRequest { 98 | var urlComponents = URLComponents(url: url, resolvingAgainstBaseURL: false)! 99 | urlComponents.path += "/" + path 100 | 101 | if !queryItems.isEmpty { 102 | // providing an empty array will cause `?` to be added in all cases 103 | urlComponents.queryItems = (urlComponents.queryItems ?? []) + queryItems 104 | } 105 | 106 | return request(url: urlComponents.url!) 107 | } 108 | 109 | /// Create a new WebSocket connection 110 | /// - Returns: The newly-created WebSocket connection 111 | internal func webSocket() -> WebSocket { 112 | let request = self.request(url: webSocketURL) 113 | let webSocket: WebSocket 114 | 115 | if let engine = engine { 116 | webSocket = WebSocket(request: request, engine: engine) 117 | } else { 118 | let pinning = evaluateCertificate.flatMap { HAStarscreamCertificatePinningImpl(evaluateCertificate: $0) } 119 | webSocket = WebSocket(request: request, certPinner: pinning, compressionHandler: WSCompression()) 120 | } 121 | 122 | return webSocket 123 | } 124 | 125 | /// Clean up the given URL 126 | /// - Parameter url: The raw URL 127 | /// - Returns: A URL with common issues removed 128 | private static func sanitize(_ url: URL) -> URL { 129 | var components = URLComponents(url: url, resolvingAgainstBaseURL: false)! 130 | 131 | for substring in [ 132 | "/api/websocket", 133 | "/api", 134 | ] { 135 | if let range = components.path.range(of: substring) { 136 | components.path.removeSubrange(range) 137 | } 138 | } 139 | 140 | // We expect no extra trailing / in the 141 | while components.path.hasSuffix("/") { 142 | // remove any trailing / 143 | components.path.removeLast() 144 | } 145 | 146 | return components.url! 147 | } 148 | 149 | public static func == (lhs: HAConnectionInfo, rhs: HAConnectionInfo) -> Bool { 150 | lhs.url == rhs.url 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | [safety@home-assistant.io][email] or by using the report/flag feature of 64 | the medium used. All complaints will be reviewed and investigated promptly and 65 | fairly. 66 | 67 | All community leaders are obligated to respect the privacy and security of the 68 | reporter of any incident. 69 | 70 | ## Enforcement Guidelines 71 | 72 | Community leaders will follow these Community Impact Guidelines in determining 73 | the consequences for any action they deem in violation of this Code of Conduct: 74 | 75 | ### 1. Correction 76 | 77 | **Community Impact**: Use of inappropriate language or other behavior deemed 78 | unprofessional or unwelcome in the community. 79 | 80 | **Consequence**: A private, written warning from community leaders, providing 81 | clarity around the nature of the violation and an explanation of why the 82 | behavior was inappropriate. A public apology may be requested. 83 | 84 | ### 2. Warning 85 | 86 | **Community Impact**: A violation through a single incident or series 87 | of actions. 88 | 89 | **Consequence**: A warning with consequences for continued behavior. No 90 | interaction with the people involved, including unsolicited interaction with 91 | those enforcing the Code of Conduct, for a specified period of time. This 92 | includes avoiding interactions in community spaces as well as external channels 93 | like social media. Violating these terms may lead to a temporary or 94 | permanent ban. 95 | 96 | ### 3. Temporary Ban 97 | 98 | **Community Impact**: A serious violation of community standards, including 99 | sustained inappropriate behavior. 100 | 101 | **Consequence**: A temporary ban from any sort of interaction or public 102 | communication with the community for a specified period of time. No public or 103 | private interaction with the people involved, including unsolicited interaction 104 | with those enforcing the Code of Conduct, is allowed during this period. 105 | Violating these terms may lead to a permanent ban. 106 | 107 | ### 4. Permanent Ban 108 | 109 | **Community Impact**: Demonstrating a pattern of violation of community 110 | standards, including sustained inappropriate behavior, harassment of an 111 | individual, or aggression toward or disparagement of classes of individuals. 112 | 113 | **Consequence**: A permanent ban from any sort of public interaction within 114 | the community. 115 | 116 | ## Attribution 117 | 118 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 119 | version 2.0, available [here][version]. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder][mozilla]. 123 | 124 | ## Adoption 125 | 126 | This Code of Conduct was first adopted January 21st, 2017 and announced in 127 | [this][coc-blog] blog post and has been updated on May 25th, 2020 to version 128 | 2.0 of the [Contributor Covenant][homepage] as announced in [this][coc2-blog] 129 | blog post. 130 | 131 | For answers to common questions about this code of conduct, see the FAQ at 132 | . Translations are available at 133 | . 134 | 135 | [coc-blog]: /blog/2017/01/21/home-assistant-governance/ 136 | [coc2-blog]: /blog/2020/05/25/code-of-conduct-updated/ 137 | [email]: mailto:safety@home-assistant.io 138 | [homepage]: http://contributor-covenant.org 139 | [mozilla]: https://github.com/mozilla/diversity 140 | [version]: https://www.contributor-covenant.org/version/2/0/code_of_conduct.html 141 | -------------------------------------------------------------------------------- /Tests/HACachedStates.test.swift: -------------------------------------------------------------------------------- 1 | @testable import HAKit 2 | import XCTest 3 | #if SWIFT_PACKAGE 4 | import HAKit_Mocks 5 | #endif 6 | 7 | internal class HACachedStatesTests: XCTestCase { 8 | private var connection: HAMockConnection! 9 | private var container: HACachesContainer! 10 | 11 | override func setUp() { 12 | super.setUp() 13 | connection = HAMockConnection() 14 | container = HACachesContainer(connection: connection) 15 | } 16 | 17 | func testKeyAccess() { 18 | _ = container.states 19 | } 20 | 21 | func testPopulateAndSubscribeInfo() throws { 22 | let cache = container.states() 23 | XCTAssertNil(cache.populateInfo) 24 | XCTAssertNil(cache.subscribeInfo) 25 | let subscribeOnlyInfo = try XCTUnwrap(cache.subscribeOnlyInfo) 26 | 27 | XCTAssertEqual(subscribeOnlyInfo.request.type, .subscribeEntities) 28 | let result1 = try subscribeOnlyInfo.transform( 29 | incoming: HACompressedStatesUpdates( 30 | data: .init( 31 | testJsonString: """ 32 | { 33 | "a": { 34 | "person.bruno": { 35 | "s": "not_home", 36 | "a": { 37 | "editable": true, 38 | "id": "bruno", 39 | "latitude": 51.8, 40 | "longitude": 4.5, 41 | "gps_accuracy": 14.1, 42 | "source": "device_tracker.iphone", 43 | "user_id": "12345", 44 | "device_trackers": [ 45 | "device_tracker.iphone_15_pro" 46 | ], 47 | "friendly_name": "Bruno" 48 | }, 49 | "c": "01HPKH5TE4HVR88WW6TN43H31X", 50 | "lc": 1707884643.671705, 51 | "lu": 1707905051.076724 52 | } 53 | } 54 | } 55 | """ 56 | ) 57 | ), 58 | current: nil, 59 | subscriptionPhase: .initial 60 | ) 61 | guard case let .replace(outgoingType) = result1 else { 62 | XCTFail("Did not replace when expected") 63 | return 64 | } 65 | 66 | XCTAssertEqual(outgoingType?.all.count, 1) 67 | XCTAssertEqual(outgoingType?.all.first?.entityId, "person.bruno") 68 | XCTAssertEqual(outgoingType?.all.first?.state, "not_home") 69 | XCTAssertEqual(outgoingType?.all.first?.domain, "person") 70 | 71 | let updateEventResult = try subscribeOnlyInfo.transform( 72 | incoming: HACompressedStatesUpdates( 73 | data: .init( 74 | testJsonString: 75 | """ 76 | { 77 | 78 | "c": { 79 | "person.bruno": { 80 | "+": { 81 | "s": "home", 82 | } 83 | } 84 | } 85 | } 86 | """ 87 | ) 88 | ), 89 | current: .init(entities: Array(outgoingType!.all)), 90 | subscriptionPhase: .iteration 91 | ) 92 | 93 | guard case let .replace(updatedOutgoingType) = updateEventResult else { 94 | XCTFail("Did not replace updated entity") 95 | return 96 | } 97 | 98 | XCTAssertEqual(updatedOutgoingType?.all.count, 1) 99 | XCTAssertEqual(updatedOutgoingType?.all.first?.entityId, "person.bruno") 100 | XCTAssertEqual(updatedOutgoingType?.all.first?.state, "home") 101 | XCTAssertEqual(updatedOutgoingType?.all.first?.domain, "person") 102 | 103 | let entityRemovalEventResult = try subscribeOnlyInfo.transform( 104 | incoming: HACompressedStatesUpdates( 105 | data: .init( 106 | testJsonString: 107 | """ 108 | { 109 | 110 | "r": [ 111 | "person.bruno" 112 | ] 113 | } 114 | """ 115 | ) 116 | ), 117 | current: .init(entities: Array(outgoingType!.all)), 118 | subscriptionPhase: .iteration 119 | ) 120 | 121 | guard case let .replace(entityRemovedOutgoingType) = entityRemovalEventResult else { 122 | XCTFail("Did not remo entity correctly") 123 | return 124 | } 125 | 126 | XCTAssertEqual(entityRemovedOutgoingType?.all.count, 0) 127 | } 128 | 129 | func testSubscriptByEntityIdReturnsCorrectly() throws { 130 | let expectedEntity: HAEntity = try .fake(id: "person.bruno") 131 | let cache = HACachedStates(entitiesDictionary: [expectedEntity.entityId: expectedEntity]) 132 | XCTAssertEqual(cache["fake.person.bruno"], expectedEntity) 133 | } 134 | 135 | func testSubscriptSetByEntityIdSetsCorrectly() throws { 136 | let expectedEntity: HAEntity = try .fake(id: "person.bruno") 137 | let expectedEntity2: HAEntity = try .fake(id: "person.bruno2") 138 | var cache = HACachedStates(entitiesDictionary: [expectedEntity.entityId: expectedEntity]) 139 | cache[expectedEntity2.entityId] = expectedEntity2 140 | XCTAssertEqual(cache["fake.person.bruno2"], expectedEntity2) 141 | } 142 | 143 | func testSetAllByEntityIdSetsAllToo() throws { 144 | let expectedEntity: HAEntity = try .fake(id: "person.bruno") 145 | let expectedEntity2: HAEntity = try .fake(id: "person.bruno2") 146 | var cache = HACachedStates(entitiesDictionary: [expectedEntity.entityId: expectedEntity]) 147 | 148 | cache.allByEntityId = [expectedEntity2.entityId: expectedEntity2] 149 | XCTAssertEqual(cache.allByEntityId, [expectedEntity2.entityId: expectedEntity2]) 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /Tests/States.test.swift: -------------------------------------------------------------------------------- 1 | @testable import HAKit 2 | import XCTest 3 | 4 | internal class StatesTests: XCTestCase { 5 | func testStateChangedRequest() { 6 | let request = HATypedSubscription.stateChanged() 7 | XCTAssertEqual(request.request.type, .subscribeEvents) 8 | XCTAssertEqual(request.request.data["event_type"] as? String, HAEventType.stateChanged.rawValue) 9 | } 10 | 11 | func testStateChangedResponseFull() throws { 12 | let data = HAData(testJsonString: """ 13 | { 14 | "event_type": "state_changed", 15 | "data": { 16 | "entity_id": "input_select.muffin", 17 | "old_state": { 18 | "entity_id": "input_select.muffin", 19 | "state": "three", 20 | "attributes": { 21 | "options": [ 22 | "one", 23 | "two", 24 | "three" 25 | ], 26 | "editable": true, 27 | "friendly_name": "muffin", 28 | "icon": "mdi:light" 29 | }, 30 | "last_changed": "2021-02-23T05:55:17.008448+00:00", 31 | "last_updated": "2021-02-23T05:55:17.008448+00:00", 32 | "context": { 33 | "id": "81c2755244fc6356fa52bffa76343f03", 34 | "parent_id": null, 35 | "user_id": "76ce52a813c44fdf80ee36f926d62328" 36 | } 37 | }, 38 | "new_state": { 39 | "entity_id": "input_select.muffin", 40 | "state": "two", 41 | "attributes": { 42 | "options": [ 43 | "one", 44 | "two", 45 | "three" 46 | ], 47 | "editable": true, 48 | "friendly_name": "muffin", 49 | "icon": "mdi:light" 50 | }, 51 | "last_changed": "2021-02-24T04:31:10.045916+00:00", 52 | "last_updated": "2021-02-24T04:31:10.045916+00:00", 53 | "context": { 54 | "id": "ebc9bf93dd90efc0770f1dc49096788f", 55 | "parent_id": null, 56 | "user_id": "76ce52a813c44fdf80ee36f926d62328" 57 | } 58 | } 59 | }, 60 | "origin": "LOCAL", 61 | "time_fired": "2021-02-24T04:31:10.045916+00:00", 62 | "context": { 63 | "id": "ebc9bf93dd90efc0770f1dc49096788f", 64 | "parent_id": null, 65 | "user_id": "76ce52a813c44fdf80ee36f926d62328" 66 | } 67 | } 68 | """) 69 | let response = try HAResponseEventStateChanged(data: data) 70 | XCTAssertEqual(response.event.type, .stateChanged) 71 | XCTAssertEqual(response.entityId, "input_select.muffin") 72 | XCTAssertEqual(response.oldState?.entityId, "input_select.muffin") 73 | XCTAssertEqual(response.newState?.entityId, "input_select.muffin") 74 | } 75 | 76 | func testStateChangedResponseNoOld() throws { 77 | let data = HAData(testJsonString: """ 78 | { 79 | "event_type": "state_changed", 80 | "data": { 81 | "entity_id": "input_select.muffin", 82 | "new_state": { 83 | "entity_id": "input_select.muffin", 84 | "state": "two", 85 | "attributes": { 86 | "options": [ 87 | "one", 88 | "two", 89 | "three" 90 | ], 91 | "editable": true, 92 | "friendly_name": "muffin", 93 | "icon": "mdi:light" 94 | }, 95 | "last_changed": "2021-02-24T04:31:10.045916+00:00", 96 | "last_updated": "2021-02-24T04:31:10.045916+00:00", 97 | "context": { 98 | "id": "ebc9bf93dd90efc0770f1dc49096788f", 99 | "parent_id": null, 100 | "user_id": "76ce52a813c44fdf80ee36f926d62328" 101 | } 102 | } 103 | }, 104 | "origin": "LOCAL", 105 | "time_fired": "2021-02-24T04:31:10.045916+00:00", 106 | "context": { 107 | "id": "ebc9bf93dd90efc0770f1dc49096788f", 108 | "parent_id": null, 109 | "user_id": "76ce52a813c44fdf80ee36f926d62328" 110 | } 111 | } 112 | """) 113 | let response = try HAResponseEventStateChanged(data: data) 114 | XCTAssertEqual(response.event.type, .stateChanged) 115 | XCTAssertEqual(response.entityId, "input_select.muffin") 116 | XCTAssertNil(response.oldState) 117 | XCTAssertEqual(response.newState?.entityId, "input_select.muffin") 118 | } 119 | 120 | func testStateChangedResponseNoOldOrNew() throws { 121 | let data = HAData(testJsonString: """ 122 | { 123 | "event_type": "state_changed", 124 | "data": { 125 | "entity_id": "input_select.muffin" 126 | }, 127 | "origin": "LOCAL", 128 | "time_fired": "2021-02-24T04:31:10.045916+00:00", 129 | "context": { 130 | "id": "ebc9bf93dd90efc0770f1dc49096788f", 131 | "parent_id": null, 132 | "user_id": "76ce52a813c44fdf80ee36f926d62328" 133 | } 134 | } 135 | """) 136 | let response = try HAResponseEventStateChanged(data: data) 137 | XCTAssertEqual(response.event.type, .stateChanged) 138 | XCTAssertEqual(response.entityId, "input_select.muffin") 139 | XCTAssertNil(response.oldState) 140 | XCTAssertNil(response.newState) 141 | } 142 | 143 | func testSubscribeEntitiesRequest() throws { 144 | let request = HATypedSubscription.subscribeEntities(data: [:]) 145 | XCTAssertEqual(request.request.type, .subscribeEntities) 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /Source/Data/HAEntity.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// An entity in Home Assistant 4 | public struct HAEntity: HADataDecodable, Hashable { 5 | /// The entity id, e.g. `sun.sun` or `light.office` 6 | public var entityId: String 7 | /// The domain of the entity id, e.g. `light` in `light.office` 8 | public var domain: String 9 | /// The current state of the entity 10 | public var state: String 11 | /// When the entity was last changed 12 | public var lastChanged: Date 13 | /// When the entity was last updated 14 | public var lastUpdated: Date 15 | /// Attributes of the entity 16 | public var attributes: HAEntityAttributes 17 | /// Context of the entity's last update 18 | public var context: HAResponseEvent.Context 19 | 20 | /// Create an entity from a data response 21 | /// - Parameter data: The data to create from 22 | /// - Throws: When the data is missing any required fields 23 | public init(data: HAData) throws { 24 | let entityId: String = try data.decode("entity_id") 25 | 26 | try self.init( 27 | entityId: entityId, 28 | domain: HAEntity.domain(from: entityId), 29 | state: data.decode("state"), 30 | lastChanged: data.decode("last_changed"), 31 | lastUpdated: data.decode("last_updated"), 32 | attributes: data.decode("attributes"), 33 | context: data.decode("context") 34 | ) 35 | } 36 | 37 | /// Create an entity from individual items 38 | /// - Parameters: 39 | /// - entityId: The entity ID 40 | /// - domain: The domain of the entity ID (optional), when nil domain will be extracted from entityId 41 | /// - state: The state 42 | /// - lastChanged: The date last changed 43 | /// - lastUpdated: The date last updated 44 | /// - attributes: The attributes of the entity 45 | /// - context: The context of the entity 46 | /// - Throws: When the attributes are missing any required fields 47 | public init( 48 | entityId: String, 49 | domain: String? = nil, 50 | state: String, 51 | lastChanged: Date, 52 | lastUpdated: Date, 53 | attributes: [String: Any], 54 | context: HAResponseEvent.Context 55 | ) throws { 56 | let domain: String = try { 57 | if let domain { 58 | domain 59 | } else { 60 | try HAEntity.domain(from: entityId) 61 | } 62 | }() 63 | precondition(entityId.starts(with: domain)) 64 | self.entityId = entityId 65 | self.domain = domain 66 | self.state = state 67 | self.lastChanged = lastChanged 68 | self.lastUpdated = lastUpdated 69 | self.attributes = try .init(domain: domain, dictionary: attributes) 70 | self.context = context 71 | } 72 | 73 | public func hash(into hasher: inout Hasher) { 74 | hasher.combine(entityId) 75 | } 76 | 77 | public static func == (lhs: HAEntity, rhs: HAEntity) -> Bool { 78 | lhs.lastUpdated == rhs.lastUpdated && lhs.entityId == rhs.entityId && lhs.state == rhs.state 79 | } 80 | 81 | internal static func domain(from entityId: String) throws -> String { 82 | guard let dot = entityId.firstIndex(of: ".") else { 83 | throw HADataError.couldntTransform(key: "entity_id") 84 | } 85 | 86 | return String(entityId[.. Any? { dictionary[key] } 94 | /// A dictionary representation of the attributes 95 | /// This contains all keys and values received, including those not parsed or handled otherwise 96 | public var dictionary: [String: Any] 97 | 98 | /// The display name for the entity, from the `friendly_name` attribute 99 | public var friendlyName: String? { self["friendly_name"] as? String } 100 | /// The icon of the entity, from the `icon` attribute 101 | /// This will be in the format `type:name`, e.g. `mdi:map` or `hass:line` 102 | public var icon: String? { self["icon"] as? String } 103 | 104 | /// For a zone-type entity, this contains parsed attributes specific to the zone 105 | public var zone: HAEntityAttributesZone? 106 | 107 | /// Create attributes from individual values 108 | /// 109 | /// `domain` is required here as it may inform the per-domain parsing. 110 | /// 111 | /// - Parameters: 112 | /// - domain: The domain of the entity whose attributes these are for 113 | /// - dictionary: The dictionary representation of the 114 | /// - Throws: When the attributes are missing any required fields, domain-specific 115 | public init(domain: String, dictionary: [String: Any]) throws { 116 | self.dictionary = dictionary 117 | 118 | if domain == "zone" { 119 | self.zone = try .init(data: .dictionary(dictionary)) 120 | } else { 121 | self.zone = nil 122 | } 123 | } 124 | } 125 | 126 | /// Entity attributes for Zones 127 | public struct HAEntityAttributesZone: HADataDecodable { 128 | /// The latitude of the center point of the zone. 129 | public var latitude: Double 130 | /// The longitude of the center point of the zone. 131 | public var longitude: Double 132 | /// The radius of the zone. The underlying measurement comes from meters. 133 | public var radius: Measurement 134 | /// To only use the zone for automation and hide it from the frontend and not use the zone for device tracker name. 135 | public var isPassive: Bool 136 | 137 | /// Create attributes from data 138 | /// - Parameter data: The data to create from 139 | /// - Throws: When the data is missing any required fields 140 | public init(data: HAData) throws { 141 | try self.init( 142 | latitude: data.decode("latitude"), 143 | longitude: data.decode("longitude"), 144 | radius: data.decode("radius", transform: { Measurement(value: $0, unit: .meters) }), 145 | isPassive: data.decode("passive") 146 | ) 147 | } 148 | 149 | /// Create attributes from values 150 | /// - Parameters: 151 | /// - latitude: The center point latitude 152 | /// - longitude: The center point longitude 153 | /// - radius: The radius of the zone 154 | /// - isPassive: Whether the zone is passive 155 | public init( 156 | latitude: Double, 157 | longitude: Double, 158 | radius: Measurement, 159 | isPassive: Bool 160 | ) { 161 | self.latitude = latitude 162 | self.longitude = longitude 163 | self.radius = radius 164 | self.isPassive = isPassive 165 | } 166 | } 167 | -------------------------------------------------------------------------------- /Source/Convenience/Event.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public extension HATypedSubscription { 4 | /// Subscribe to one or all events on the event bus 5 | /// 6 | /// - Parameter type: The event type to subscribe to. Pass `.all` to subscribe to all events. 7 | /// - Returns: A typed subscriptions that can be sent via `HAConnection` 8 | static func events( 9 | _ type: HAEventType 10 | ) -> HATypedSubscription { 11 | var data: [String: Any] = [:] 12 | 13 | if let rawType = type.rawValue { 14 | data["event_type"] = rawType 15 | } 16 | 17 | return .init(request: .init(type: .subscribeEvents, data: data)) 18 | } 19 | } 20 | 21 | internal extension HATypedRequest { 22 | static func unsubscribe( 23 | _ identifier: HARequestIdentifier 24 | ) -> HATypedRequest { 25 | .init(request: .init( 26 | type: .unsubscribeEvents, 27 | data: ["subscription": identifier.rawValue], 28 | shouldRetry: false 29 | )) 30 | } 31 | } 32 | 33 | /// The type of the event 34 | public struct HAEventType: RawRepresentable, Hashable, ExpressibleByStringLiteral, ExpressibleByNilLiteral { 35 | /// The underlying string representing the event, or nil for all events 36 | public var rawValue: String? 37 | /// Create a type instance with a given string name 38 | /// - Parameter rawValue: The string name 39 | public init(rawValue: String?) { 40 | self.rawValue = rawValue 41 | } 42 | 43 | /// Create a type instance via a string literal 44 | /// - Parameter value: The string literal 45 | public init(stringLiteral value: StringLiteralType) { 46 | self.init(rawValue: value) 47 | } 48 | 49 | /// Create a type instance via a nil literal, representing all events 50 | /// - Parameter nilLiteral: Unused 51 | public init(nilLiteral: ()) { 52 | self.init(rawValue: nil) 53 | } 54 | 55 | /// All events 56 | public static var all: Self = nil 57 | 58 | // rule of thumb: any event available in `core` is valid for this list 59 | 60 | /// `call_service` 61 | public static var callService: Self = "call_service" 62 | /// `component_loaded` 63 | public static var componentLoaded: Self = "component_loaded" 64 | /// `core_config_updated` 65 | public static var coreConfigUpdated: Self = "core_config_updated" 66 | /// `homeassistant_close` 67 | public static var homeassistantClose: Self = "homeassistant_close" 68 | /// `homeassistant_final_write` 69 | public static var homeassistantFinalWrite: Self = "homeassistant_final_write" 70 | /// `homeassistant_start` 71 | public static var homeassistantStart: Self = "homeassistant_start" 72 | /// `homeassistant_started` 73 | public static var homeassistantStarted: Self = "homeassistant_started" 74 | /// `homeassistant_stop` 75 | public static var homeassistantStop: Self = "homeassistant_stop" 76 | /// `logbook_entry` 77 | public static var logbookEntry: Self = "logbook_entry" 78 | /// `platform_discovered` 79 | public static var platformDiscovered: Self = "platform_discovered" 80 | /// `service_registered` 81 | public static var serviceRegistered: Self = "service_registered" 82 | /// `service_removed` 83 | public static var serviceRemoved: Self = "service_removed" 84 | /// `shopping_list_updated` 85 | public static var shoppingListUpdated: Self = "shopping_list_updated" 86 | /// `state_changed` 87 | public static var stateChanged: Self = "state_changed" 88 | /// `themes_updated` 89 | public static var themesUpdated: Self = "themes_updated" 90 | /// `timer_out_of_sync` 91 | public static var timerOutOfSync: Self = "timer_out_of_sync" 92 | } 93 | 94 | /// An event fired on the event bus 95 | public struct HAResponseEvent: HADataDecodable { 96 | /// The type of event 97 | public var type: HAEventType 98 | /// When the event was fired 99 | public var timeFired: Date 100 | /// Data that came with the event 101 | public var data: [String: Any] 102 | /// The origin of the event 103 | public var origin: Origin 104 | /// The context of the event, e.g. who executed it 105 | public var context: Context 106 | 107 | /// The origin of the event 108 | public enum Origin: String, HADecodeTransformable { 109 | /// Local, aka added to the event bus via a component 110 | case local = "LOCAL" 111 | /// Remote, aka added to the event bus via an API call 112 | case remote = "REMOTE" 113 | } 114 | 115 | /// The context of the event 116 | public struct Context: HADataDecodable { 117 | /// The identifier for this event 118 | public var id: String 119 | /// The user id which triggered the event, if there was one 120 | public var userId: String? 121 | /// The identifier of the parent event for this event 122 | public var parentId: String? 123 | 124 | /// Create with data 125 | /// - Parameter data: The data from the server 126 | /// - Throws: If any required keys are missing 127 | public init(data: HAData) throws { 128 | try self.init( 129 | id: data.decode("id"), 130 | userId: data.decode("user_id", fallback: nil), 131 | parentId: data.decode("parent_id", fallback: nil) 132 | ) 133 | } 134 | 135 | /// Create with information 136 | /// - Parameters: 137 | /// - id: The id of the event 138 | /// - userId: The user id of the event 139 | /// - parentId: The parent id of the event 140 | public init( 141 | id: String, 142 | userId: String?, 143 | parentId: String? 144 | ) { 145 | self.id = id 146 | self.userId = userId 147 | self.parentId = parentId 148 | } 149 | } 150 | 151 | /// Create with data 152 | /// - Parameter data: The data from the server 153 | /// - Throws: If any required keys are missing 154 | public init(data: HAData) throws { 155 | try self.init( 156 | type: .init(rawValue: data.decode("event_type")), 157 | timeFired: data.decode("time_fired"), 158 | data: data.decode("data", fallback: [:]), 159 | origin: data.decode("origin"), 160 | context: data.decode("context") 161 | ) 162 | } 163 | 164 | /// Create with information 165 | /// - Parameters: 166 | /// - type: The type of the event 167 | /// - timeFired: The time fired 168 | /// - data: The data 169 | /// - origin: The origin 170 | /// - context: The context 171 | public init( 172 | type: HAEventType, 173 | timeFired: Date, 174 | data: [String: Any], 175 | origin: Origin, 176 | context: Context 177 | ) { 178 | self.type = type 179 | self.timeFired = timeFired 180 | self.data = data 181 | self.origin = origin 182 | self.context = context 183 | } 184 | } 185 | -------------------------------------------------------------------------------- /Tests/HAEntity.test.swift: -------------------------------------------------------------------------------- 1 | @testable import HAKit 2 | import XCTest 3 | 4 | internal extension HAEntity { 5 | static func fake(id: String) throws -> HAEntity { 6 | try .init( 7 | entityId: "fake.\(id)", 8 | domain: "fake", 9 | state: "fake", 10 | lastChanged: Date(), 11 | lastUpdated: Date(), 12 | attributes: [:], 13 | context: .init(id: "", userId: nil, parentId: nil) 14 | ) 15 | } 16 | } 17 | 18 | internal class HAEntityTests: XCTestCase { 19 | func testWithInvalidData() throws { 20 | let data = HAData(value: [:]) 21 | XCTAssertThrowsError(try HAEntity(data: data)) 22 | } 23 | 24 | func testWithInvalidEntityId() throws { 25 | let data = HAData(testJsonString: """ 26 | { 27 | "entity_id": "bob", 28 | "state": "two", 29 | "attributes": {}, 30 | "last_changed": "2021-02-20T05:14:52.625818+00:00", 31 | "last_updated": "2021-02-23T05:55:17.008448+00:00", 32 | "context": { 33 | "id": "27f121fd8bfa49f92f7094d8cb3eb2c1", 34 | "parent_id": null, 35 | "user_id": null 36 | } 37 | } 38 | """) 39 | XCTAssertThrowsError(try HAEntity(data: data)) 40 | } 41 | 42 | func testWithData() throws { 43 | let data = HAData(testJsonString: """ 44 | { 45 | "entity_id": "input_select.muffin", 46 | "state": "two", 47 | "attributes": { 48 | "options": [ 49 | "one", 50 | "two", 51 | "three" 52 | ], 53 | "editable": true, 54 | "friendly_name": "muffin", 55 | "icon": "mdi:light" 56 | }, 57 | "last_changed": "2021-02-20T05:14:52.625818+00:00", 58 | "last_updated": "2021-02-23T05:55:17.008448+00:00", 59 | "context": { 60 | "id": "27f121fd8bfa49f92f7094d8cb3eb2c1", 61 | "parent_id": null, 62 | "user_id": null 63 | } 64 | } 65 | """) 66 | let entity = try HAEntity(data: data) 67 | XCTAssertEqual(entity, entity) 68 | XCTAssertEqual(entity.hashValue, entity.hashValue) 69 | var copy = entity 70 | copy.lastUpdated = Date() 71 | XCTAssertNotEqual(entity, copy) 72 | copy = entity 73 | copy.entityId = "different_id" 74 | XCTAssertNotEqual(entity, copy) 75 | copy = entity 76 | copy.state = "some_other_state" 77 | XCTAssertNotEqual(entity, copy) 78 | copy = entity 79 | copy.context = .init(id: "some_other_id", userId: nil, parentId: nil) 80 | XCTAssertEqual(entity, copy, "some other value changing is fine") 81 | 82 | XCTAssertEqual(entity.entityId, "input_select.muffin") 83 | XCTAssertEqual(entity.domain, "input_select") 84 | XCTAssertEqual(entity.state, "two") 85 | XCTAssertEqual(entity.attributes["options"] as? [String], ["one", "two", "three"]) 86 | XCTAssertEqual(entity.attributes["editable"] as? Bool, true) 87 | XCTAssertEqual(entity.attributes["friendly_name"] as? String, "muffin") 88 | XCTAssertEqual(entity.attributes.friendlyName, "muffin") 89 | XCTAssertEqual(entity.attributes["icon"] as? String, "mdi:light") 90 | XCTAssertEqual(entity.attributes.icon, "mdi:light") 91 | XCTAssertEqual(entity.context.id, "27f121fd8bfa49f92f7094d8cb3eb2c1") 92 | XCTAssertNil(entity.context.parentId) 93 | XCTAssertNil(entity.context.userId) 94 | 95 | let changed = Calendar.current.dateComponents( 96 | in: TimeZone(identifier: "UTC")!, 97 | from: entity.lastChanged 98 | ) 99 | XCTAssertEqual(changed.year, 2021) 100 | XCTAssertEqual(changed.month, 2) 101 | XCTAssertEqual(changed.day, 20) 102 | XCTAssertEqual(changed.hour, 5) 103 | XCTAssertEqual(changed.minute, 14) 104 | XCTAssertEqual(changed.second, 52) 105 | XCTAssertEqual(changed.nanosecond ?? -1, 625_000_000, accuracy: 100_000) 106 | 107 | let updated = Calendar.current.dateComponents( 108 | in: TimeZone(identifier: "UTC")!, 109 | from: entity.lastUpdated 110 | ) 111 | XCTAssertEqual(updated.year, 2021) 112 | XCTAssertEqual(updated.month, 2) 113 | XCTAssertEqual(updated.day, 23) 114 | XCTAssertEqual(updated.hour, 5) 115 | XCTAssertEqual(updated.minute, 55) 116 | XCTAssertEqual(updated.second, 17) 117 | XCTAssertEqual(updated.nanosecond ?? -1, 008_000_000, accuracy: 100_000) 118 | } 119 | 120 | func testWithDataForZone() throws { 121 | let data = HAData(testJsonString: """ 122 | { 123 | "entity_id": "zone.south_park", 124 | "state": "zoning", 125 | "attributes": { 126 | "latitude": 37.78163720658547, 127 | "longitude": -122.3939609527588, 128 | "radius": 50.0, 129 | "passive": false, 130 | "editable": true, 131 | "friendly_name": "south park", 132 | "icon": "mdi:map-marker" 133 | }, 134 | "last_changed": "2021-03-12T18:35:00.997355+00:00", 135 | "last_updated": "2021-03-12T18:35:00.997355+00:00", 136 | "context": { 137 | "id": "1dc14234d6a72d0835abce53d2f62dbd", 138 | "parent_id": null, 139 | "user_id": null 140 | } 141 | } 142 | """) 143 | let entity = try HAEntity(data: data) 144 | XCTAssertEqual(entity.entityId, "zone.south_park") 145 | XCTAssertEqual(entity.domain, "zone") 146 | XCTAssertEqual(entity.state, "zoning") 147 | 148 | XCTAssertEqual(entity.attributes.friendlyName, "south park") 149 | XCTAssertEqual(entity.attributes.icon, "mdi:map-marker") 150 | 151 | let zoneAttributes = try XCTUnwrap(entity.attributes.zone) 152 | XCTAssertEqual(zoneAttributes.latitude, 37.78163720658547, accuracy: 0.0000001) 153 | XCTAssertEqual(zoneAttributes.longitude, -122.3939609527588, accuracy: 0.0000001) 154 | XCTAssertEqual(zoneAttributes.radius.converted(to: .meters).value, 50.0, accuracy: 0.000001) 155 | 156 | let changed = Calendar.current.dateComponents( 157 | in: TimeZone(identifier: "UTC")!, 158 | from: entity.lastChanged 159 | ) 160 | XCTAssertEqual(changed.year, 2021) 161 | XCTAssertEqual(changed.month, 3) 162 | XCTAssertEqual(changed.day, 12) 163 | XCTAssertEqual(changed.hour, 18) 164 | XCTAssertEqual(changed.minute, 35) 165 | XCTAssertEqual(changed.second, 00) 166 | XCTAssertEqual(changed.nanosecond ?? -1, 997_000_000, accuracy: 100_000) 167 | 168 | let updated = Calendar.current.dateComponents( 169 | in: TimeZone(identifier: "UTC")!, 170 | from: entity.lastUpdated 171 | ) 172 | XCTAssertEqual(updated, changed) 173 | } 174 | } 175 | -------------------------------------------------------------------------------- /Source/Internal/HAReconnectManager.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | #if canImport(Network) 4 | import Network 5 | #endif 6 | 7 | internal enum HAReconnectManagerError: Error { 8 | case timeout 9 | case lateFireReset 10 | } 11 | 12 | internal protocol HAReconnectManagerDelegate: AnyObject { 13 | func reconnectManagerWantsReconnection(_ manager: HAReconnectManager) 14 | func reconnect( 15 | _ manager: HAReconnectManager, 16 | wantsDisconnectFor error: Error 17 | ) 18 | func reconnectManager( 19 | _ manager: HAReconnectManager, 20 | pingWithCompletion handler: @escaping (Result) -> Void 21 | ) -> HACancellable 22 | } 23 | 24 | internal protocol HAReconnectManager: AnyObject { 25 | var delegate: HAReconnectManagerDelegate? { get set } 26 | var reason: HAConnectionState.DisconnectReason { get } 27 | 28 | func didStartInitialConnect() 29 | func didDisconnectPermanently() 30 | func didDisconnectRejected() 31 | func didDisconnectTemporarily(error: Error?) 32 | func didFinishConnect() 33 | } 34 | 35 | internal class HAReconnectManagerImpl: HAReconnectManager { 36 | struct PingConfig { 37 | let interval: Measurement 38 | let timeout: Measurement 39 | let lateFireReset: Measurement 40 | 41 | func shouldReset(forExpectedFire expectedFire: Date) -> Bool { 42 | let timeSinceExpectedFire = Measurement( 43 | value: HAGlobal.date().timeIntervalSince(expectedFire), 44 | unit: .seconds 45 | ) 46 | return timeSinceExpectedFire > lateFireReset 47 | } 48 | } 49 | 50 | static let pingConfig: PingConfig = .init( 51 | interval: .init(value: 1, unit: .minutes), 52 | timeout: .init(value: 30, unit: .seconds), 53 | lateFireReset: .init(value: 5, unit: .minutes) 54 | ) 55 | 56 | weak var delegate: HAReconnectManagerDelegate? 57 | 58 | #if canImport(Network) 59 | let pathMonitor = NWPathMonitor() 60 | #endif 61 | 62 | private struct State { 63 | var retryCount: Int = 0 64 | var lastError: Error? 65 | var nextTimerDate: Date? 66 | var isRejected: Bool = false 67 | @HASchedulingTimer var reconnectTimer: Timer? { 68 | didSet { 69 | nextTimerDate = reconnectTimer?.fireDate 70 | } 71 | } 72 | 73 | var lastPingDuration: Measurement? 74 | @HASchedulingTimer var pingTimer: Timer? 75 | 76 | mutating func reset() { 77 | lastPingDuration = nil 78 | pingTimer = nil 79 | reconnectTimer = nil 80 | lastError = nil 81 | retryCount = 0 82 | isRejected = false 83 | } 84 | } 85 | 86 | private let state = HAProtected(value: .init()) 87 | 88 | var reason: HAConnectionState.DisconnectReason { 89 | state.read { state in 90 | if state.isRejected { 91 | return .rejected 92 | } 93 | 94 | guard let nextTimerDate = state.nextTimerDate else { 95 | return .disconnected 96 | } 97 | 98 | return .waitingToReconnect( 99 | lastError: state.lastError, 100 | atLatest: nextTimerDate, 101 | retryCount: state.retryCount 102 | ) 103 | } 104 | } 105 | 106 | init() { 107 | #if canImport(Network) 108 | pathMonitor.pathUpdateHandler = { [weak self] _ in 109 | // if we're waiting to reconnect, try now! 110 | self?.state.read(\.reconnectTimer)?.fire() 111 | } 112 | pathMonitor.start(queue: .main) 113 | #endif 114 | } 115 | 116 | private func reset() { 117 | state.mutate { state in 118 | state.reset() 119 | } 120 | } 121 | 122 | @objc private func retryTimerFired(_ timer: Timer) { 123 | delegate?.reconnectManagerWantsReconnection(self) 124 | } 125 | 126 | func didStartInitialConnect() { 127 | reset() 128 | } 129 | 130 | func didDisconnectPermanently() { 131 | reset() 132 | } 133 | 134 | func didDisconnectRejected() { 135 | state.mutate { state in 136 | state.reset() 137 | state.isRejected = true 138 | } 139 | } 140 | 141 | func didFinishConnect() { 142 | reset() 143 | schedulePing() 144 | } 145 | 146 | func didDisconnectTemporarily(error: Error?) { 147 | state.mutate { state in 148 | state.lastError = error 149 | 150 | let delay: TimeInterval = { 151 | switch state.retryCount { 152 | case 0: return 0.0 153 | case 1: return 5.0 154 | case 2, 3: return 10.0 155 | default: return 15.0 156 | } 157 | }() 158 | 159 | state.reconnectTimer = Timer( 160 | fireAt: HAGlobal.date().addingTimeInterval(delay), 161 | interval: 0, 162 | target: self, 163 | selector: #selector(retryTimerFired(_:)), 164 | userInfo: nil, 165 | repeats: false 166 | ) 167 | state.retryCount += 1 168 | } 169 | } 170 | 171 | private func schedulePing() { 172 | let timer = Timer( 173 | fire: HAGlobal.date().addingTimeInterval(Self.pingConfig.interval.converted(to: .seconds).value), 174 | interval: 0, 175 | repeats: false 176 | ) { [weak self] timer in 177 | guard !Self.pingConfig.shouldReset(forExpectedFire: timer.fireDate) else { 178 | // Ping timer fired very far after our expected fire date, indicating we were probably suspended 179 | // The WebSocket connection is going to fail after such a long interval; force a faster reconnect. 180 | self?.handle(pingResult: .failure(HAReconnectManagerError.lateFireReset)) 181 | return 182 | } 183 | 184 | self?.sendPing() 185 | } 186 | timer.tolerance = 10.0 187 | 188 | state.mutate { state in 189 | state.pingTimer = timer 190 | } 191 | } 192 | 193 | private func sendPing() { 194 | var timeoutTimer: Timer? 195 | var pingToken: HACancellable? 196 | let start = HAGlobal.date() 197 | 198 | pingToken = delegate?.reconnectManager(self, pingWithCompletion: { [weak self] result in 199 | let end = HAGlobal.date() 200 | let timeInterval = end.timeIntervalSince(start) 201 | 202 | timeoutTimer?.invalidate() 203 | 204 | self?.handle(pingResult: result.map { 205 | Measurement(value: timeInterval, unit: .seconds) 206 | }) 207 | }) 208 | 209 | timeoutTimer = Timer( 210 | timeInterval: Self.pingConfig.timeout.converted(to: .seconds).value, 211 | repeats: false 212 | ) { [weak self] _ in 213 | pingToken?.cancel() 214 | self?.handle(pingResult: .failure(HAReconnectManagerError.timeout)) 215 | } 216 | timeoutTimer?.tolerance = 5.0 217 | 218 | state.mutate { state in 219 | state.pingTimer = timeoutTimer 220 | } 221 | } 222 | 223 | private func handle(pingResult: Result, Error>) { 224 | state.mutate { state in 225 | state.pingTimer = nil 226 | 227 | if case let .success(duration) = pingResult { 228 | state.lastPingDuration = duration 229 | } 230 | } 231 | 232 | switch pingResult { 233 | case .success: 234 | schedulePing() 235 | case let .failure(error): 236 | delegate?.reconnect(self, wantsDisconnectFor: error) 237 | } 238 | } 239 | } 240 | 241 | // for tests 242 | extension HAReconnectManagerImpl { 243 | var reconnectTimer: Timer? { 244 | state.read(\.reconnectTimer) 245 | } 246 | 247 | var lastPingDuration: Measurement? { 248 | state.read(\.lastPingDuration) 249 | } 250 | 251 | var pingTimer: Timer? { 252 | state.read(\.pingTimer) 253 | } 254 | 255 | var retryCount: Int { 256 | state.read(\.retryCount) 257 | } 258 | } 259 | --------------------------------------------------------------------------------