// // HomeAssistantServer.swift // XIONControlPanel // // Created by James Magahern on 6/18/25. // Copyright © 2025 XION. All rights reserved. // import Combine import Foundation class HomeAssistantServer: Server { var connectionStatus: ConnectionStatus = .disconnected weak var delegate: ServerDelegate? // Configure: private let authToken: String = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiI2ZTAwODI1N2Y4N2Q0OWIxYWRkN2ExNTBhOWRmNGZiZCIsImlhdCI6MTc1MDMwMDg5NiwiZXhwIjoyMDY1NjYwODk2fQ.KRf9GpRZdpW9Z_vkbL3sl74rgKS7eAyc8kO0a5jLTgg" // Configure: private static let filterLabel = "control_panel" private let url: URL private let liaison: Liaison private var connectionContinuations: [CheckedContinuation] = [] private var eventTask: Task<(), any Error>? = nil private var knownDevices: Set = .init() typealias NoResult = [String: String] required init(_ url: URL) { self.url = url let websocketURL = url.appending(component: "websocket").websocket() self.liaison = Liaison(websocketURL: websocketURL) } func connect(_ completion: @escaping ((any Error)?) -> Void) { guard self.eventTask == nil else { completion(nil) return } self.eventTask = Task { for await event in try await liaison.partialEvents() { do { try await handleLiaisonEvent(event) } catch { encounteredWebsocketError(error) } } print("Event loop exited") connectionStatus = .error } completion(nil) } func disconnect(_ completion: ((any Error)?) -> Void) { self.eventTask?.cancel() self.eventTask = nil Task { await liaison.disconnect() } completion(nil) } func fetchDevices(_ completion: @escaping (Result<[AnyDevice], any Error>) -> Void) { guard connectionStatus != .error else { // Need to report this state to the UI. completion(.failure(ServerError.disconnected)) return } Task { await waitConnected() do { guard case let .result(registryEntries) = try await liaison.sendMessage(RegistryEntriesMessage()) else { fatalError() } let filteredEntries = registryEntries.filter { $0.labels.contains(Self.filterLabel) } guard case let .result(states) = try await liaison.sendMessage(GetStatesMessage()) else { fatalError() } let stateMap = states.reduce(into: [String: DeviceState]()) { partialResult, state in partialResult[state.entityId] = switch state.state { case .on: .on case .off: .off case .other(_): .off } } let devices = filteredEntries.map { entry in HomeAssistantDevice(entity: entry, state: stateMap[entry.entityId] ?? .on) } self.knownDevices = Set(devices) let anyDevices = devices.map { $0.eraseToAnyDevice() } Task { @MainActor in completion(.success(anyDevices)) } } catch { Task { @MainActor in completion(.failure(error)) } } } } func toggleDevice(_ device: AnyDevice, state: DeviceState, completion: @escaping ((any Error)?) -> Void) { guard connectionStatus != .error else { completion(ServerError.disconnected) return } Task { await waitConnected() do { let entityID = device.serial let service: CallServiceMessage.Service = switch state { case .off: .turnOff case .on: .turnOn } var domain: CallServiceMessage.ServiceDomain = .switch if entityID.starts(with: "light.") { // device.type is a UI concept, not related to what HA knows about it. domain = .light } let message = CallServiceMessage( entityID: entityID, service: service, domain: domain ) try await liaison.sendMessage(message) Task { @MainActor in completion(nil) } } catch { Task { @MainActor in completion(error) } } } } func responsibleForDevice(_ device: AnyDevice) -> Bool { return knownDevices.contains { knownDevice in knownDevice.serial == device.serial } } // MARK: - @discardableResult private func waitConnected() async -> Bool { if connectionStatus == .connecting || connectionStatus == .disconnected { return await withCheckedContinuation { continuation in self.connectionContinuations.append(continuation) } } else if connectionStatus == .error { return false } return true } private func encounteredWebsocketError(_ error: any Error) { print("Websocket Error: \(error)") self.connectionStatus = .error self.eventTask?.cancel() self.eventTask = nil } private func handleLiaisonEvent(_ event: Liaison.Event) async throws { switch event { case .partialEvent(let partialEvent, _): switch partialEvent.type { case .authRequired: Task { await authenticate() } case .authOK: Task { await subscribeToStateEvents() didConnectToWebsocket() } case .event: if let subEvent = partialEvent.event { handleSubscriptionEvent(subEvent) } default: print("Unhandled event: \(partialEvent)") break } case .pong: didConnectToWebsocket() case .disconnected(let error): encounteredWebsocketError(error) case .error(let error): encounteredWebsocketError(error) } } private func handleSubscriptionEvent(_ event: SubscriptionEvent) { guard let device = knownDevices.first(where: { $0.serial == event.data.entityId }) else { return } device.state = switch event.data.newState.state { case .on: .on case .off: .off case .other(_): .off } delegate?.server(self, deviceChangedState: device.eraseToAnyDevice()) } private func subscribeToStateEvents() async { do { try await liaison.sendMessage(SubscribeEventsMessage()) } catch { print("Error subscribing to events: \(error)") } } private func didConnectToWebsocket() { self.connectionStatus = .connected connectionContinuations.forEach { continuation in continuation.resume(returning: true) } connectionContinuations.removeAll() } private func authenticate() async { do { try await liaison.sendMessage(AuthMessage(token: authToken)) } catch { print("Error authenticating: \(error)") } } // MARK: - Liaison actor Liaison { let websocketURL: URL private typealias Continuation = AsyncStream.Continuation private typealias Reply = (Data) -> Void private var websocketTask: URLSessionWebSocketTask? = nil private var eventTask: Task<(), Never>? = nil private var pingTask: Task<(), Never>? = nil private var pendingMessages: [MessageID: Reply] = [:] private var continuations: [UUID: Continuation] = [:] fileprivate init(websocketURL: URL) { self.websocketURL = websocketURL } fileprivate func connect() { spawnWebsocketTaskIfNecessary() } fileprivate func disconnect() { self.websocketTask?.cancel() self.websocketTask = nil } @discardableResult fileprivate func sendMessage(_ message: M) async throws -> MessageResult { return try await withCheckedThrowingContinuation { continuation in Task { var outgoingMessage = message outgoingMessage.id = outgoingMessage.nextMessageID() // important that we update this here. self.pendingMessages[outgoingMessage.id] = { response in do { let decodedResponse = try JSONDecoder().decode(HomeAssistantServer.Event.self, from: response) if let result = decodedResponse.result { continuation.resume(returning: .result(result)) } else if let success = decodedResponse.success { continuation.resume(with: .success(.success(success))) } else { let error = decodedResponse.error ?? ErrorResult(code: "unknown", message: "unknown") continuation.resume(throwing: error) } } catch { print("Response decoding error: \(error)") continuation.resume(throwing: error) } self.pendingMessages.removeValue(forKey: outgoingMessage.id) } let encodedMessage = try JSONEncoder().encode(outgoingMessage) let jsonString = String(data: encodedMessage, encoding: .utf8)! try await websocketTask?.send(.string(jsonString)) } } } fileprivate func partialEvents() throws -> AsyncStream { return AsyncStream { continuation in let uuid = UUID() self.continuations[uuid] = continuation continuation.onTermination = { termination in Task { await self.removeContinuation(uuid: uuid) } } spawnWebsocketTaskIfNecessary() } } private func removeContinuation(uuid: UUID) { print("removing continuation: \(uuid)") self.continuations.removeValue(forKey: uuid) } private func restartWebsocket() { disconnect() spawnWebsocketTaskIfNecessary() } private func spawnWebsocketTaskIfNecessary() { guard self.websocketTask == nil else { return } let websocketTask = URLSession.shared.webSocketTask(with: websocketURL) self.websocketTask = websocketTask websocketTask.resume() // Event Task eventTask?.cancel() eventTask = Task.detached { [weak self] in guard let self else { return } do { while websocketTask.state == .running { switch try await websocketTask.receive() { case .string(let string): let data = string.data(using: .utf8)! await handlePartialEvent(data: data) case .data(let data): await handlePartialEvent(data: data) default: break } } print("Websocket not running: \(String(describing: websocketTask.error))") } catch { await yield(.error(error)) } } // Ping Task pingTask?.cancel() pingTask = Task.detached { [weak self] in guard let self else { return } guard let websocket = await self.websocketTask else { return } var pinging = true while pinging { do { try await Task.sleep(for: .seconds(5)) } catch { print("Task cancelled.") } websocket.sendPing { error in if let error { pinging = false print("Ping error: \(error). Disconnecting.") Task { await self.disconnect() await self.yield(.disconnected(error)) } } else { print("pong!") Task { await self.yield(.pong) } } } } } } private func handlePartialEvent(data: Data) { do { let event = try JSONDecoder().decode(PartialEvent.self, from: data) if event.type == .result { guard let id = event.id else { return } if let resultHandler = self.pendingMessages[id] { resultHandler(data) } else { print("No result handler for id: \(id)") } } else { yield(.partialEvent(event, data)) } } catch { // we don't want to bubble this up, because we don't know what the server might send us. print("PartialEvent decoding error: \(error)") } } private func yield(_ event: Event) { for (_, continuation) in continuations { continuation.yield(event) } } enum Event { case partialEvent(PartialEvent, Data) case pong case disconnected(any Error) case error(any Error) } } // MARK: - Types struct PartialEvent: Decodable { let id: Int64? let type: EventType let event: SubscriptionEvent? } struct Event: Decodable { let id: Int? let type: EventType let result: Result? let success: Bool? let error: ErrorResult? } struct SubscriptionEvent: Decodable { let eventType: SubscriptionEventType let data: SubscriptionEventData enum SubscriptionEventType: String, Codable { case stateChanged = "state_changed" } struct SubscriptionEventData: Decodable { let entityId: String let newState: State enum CodingKeys: String, CodingKey { case entityId = "entity_id" case newState = "new_state" } } enum CodingKeys: String, CodingKey { case eventType = "event_type" case data } } enum EventType: String, Codable { case authRequired = "auth_required" case authOK = "auth_ok" case result = "result" case event = "event" } struct ErrorResult: Codable, Swift.Error { let code: String let message: String } enum ServerError: Swift.Error { case disconnected } // MARK: - Result Types } extension HomeAssistantDevice { fileprivate convenience init(entity: RegistryEntriesMessage.Entry, state: DeviceState) { var type: DeviceType = .switch if entity.labels.contains("control_panel_light") { type = .light } self.init( name: entity.name?.replacingOccurrences(of: " Switch", with: "") ?? entity.entityId, serial: entity.entityId, type: type, state: state ) } } typealias MessageID = Int64? extension MessageID { private static var counter: Int64 = 1 static func next() -> Self { OSAtomicIncrement64(&Self.counter) return Self.counter } } struct State: Decodable { let entityId: String let state: State enum State: Decodable { case on case off case other(String) init(from decoder: Decoder) throws { let container = try decoder.singleValueContainer() let stringValue = try container.decode(String.self) switch stringValue.lowercased() { case "on": self = .on case "off": self = .off default: self = .other(stringValue) } } } struct Attributes: Decodable { let friendlyName: String? enum CodingKeys: String, CodingKey { case friendlyName = "friendly_name" } } enum CodingKeys: String, CodingKey { case entityId = "entity_id" case state } } // MARK: - Message Types typealias Null = String? enum MessageResult { case result(R) case success(Bool) } fileprivate protocol Message: Encodable { associatedtype Response: Decodable var id: MessageID { get set } var type: String { get } func nextMessageID() -> MessageID } extension Message { func nextMessageID() -> MessageID { .next() } } fileprivate struct RegistryEntriesMessage: Message { var id: MessageID = nil let type: String = "config/entity_registry/list" typealias Response = [Entry] struct Entry: Codable { let id: String let entityId: String let name: String? let labels: [String] enum CodingKeys: String, CodingKey { case id case name case labels case entityId = "entity_id" } } } fileprivate struct GetStatesMessage: Message { var id: MessageID = nil let type: String = "get_states" typealias Response = [State] } fileprivate struct AuthMessage: Message { var id: MessageID = nil let type: String = "auth" let token: String // No ID for auth messages func nextMessageID() -> MessageID { nil } enum CodingKeys: String, CodingKey { case id case type case token = "access_token" } typealias Response = Null } fileprivate struct SubscribeEventsMessage: Message { var id: MessageID = nil let type: String = "subscribe_events" let eventType: SubscribeEventType = .stateChanged enum SubscribeEventType: String, Codable { case stateChanged = "state_changed" } enum CodingKeys: String, CodingKey { case id case type case eventType = "event_type" } typealias Response = Null } fileprivate struct CallServiceMessage: Message { var id: MessageID = nil let type: String = "call_service" let service: Service let domain: ServiceDomain let target: Target init(entityID: String, service: Service, domain: ServiceDomain = .switch) { self.service = service self.domain = domain self.target = Target(entityID: entityID) } enum ServiceDomain: String, Codable { case `switch` case light } enum Service: String, Codable { case toggle case turnOn = "turn_on" case turnOff = "turn_off" } struct Target: Codable { let entityID: String enum CodingKeys: String, CodingKey { case entityID = "entity_id" } } typealias Response = Null }