├── 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 |
--------------------------------------------------------------------------------