// // HomeAssistantServer.swift // XIONControlPanel // // Created by James Magahern on 6/18/25. // Copyright © 2025 XION. All rights reserved. // import Foundation final class HomeAssistantServer: Server { private(set) var connectionStatus: ConnectionStatus = .disconnected weak var delegate: ServerDelegate? private static let filterLabel = "control_panel" private static let lightLabel = "control_panel_light" private static let heartbeatInterval: TimeInterval = 15 private static let connectionTimeout: TimeInterval = 30 private static let reconnectDelays: [TimeInterval] = [1, 2, 4, 8, 15] private let liaison: Liaison private let authToken: String? private var wantsConnection = false private var connectionGeneration = 0 private var connectionTask: Task? private var heartbeatTask: Task? private var knownDevices: Set = [] private var pendingFetchCompletions: [(Result<[AnyDevice], Error>) -> Void] = [] private var fetchTask: Task? private var fetchGeneration = 0 required init(_ url: URL) { let websocketURL = url.appendingPathComponent("websocket").websocket() self.liaison = Liaison(websocketURL: websocketURL) self.authToken = Self.configuredAccessToken() } deinit { connectionTask?.cancel() heartbeatTask?.cancel() fetchTask?.cancel() } func connect(_ completion: @escaping (Error?) -> Void) { dispatchPrecondition(condition: .onQueue(.main)) do { try startConnectionIfNecessary() completion(nil) } catch { setConnectionStatus(.error) completion(error) } } func disconnect(_ completion: (Error?) -> Void) { dispatchPrecondition(condition: .onQueue(.main)) wantsConnection = false connectionGeneration += 1 connectionTask?.cancel() connectionTask = nil stopHeartbeat() fetchGeneration += 1 fetchTask?.cancel() fetchTask = nil let pendingCompletions = pendingFetchCompletions pendingFetchCompletions.removeAll() pendingCompletions.forEach { $0(.failure(HomeAssistantError.disconnected)) } setConnectionStatus(.disconnected) let generation = connectionGeneration Task { await liaison.disconnect(generation: generation) } print("[HomeAssistant] Disconnected") completion(nil) } func fetchDevices(_ completion: @escaping (Result<[AnyDevice], Error>) -> Void) { Task { @MainActor [weak self] in guard let self else { completion(.failure(HomeAssistantError.disconnected)) return } self.pendingFetchCompletions.append(completion) guard self.fetchTask == nil else { return } self.fetchGeneration += 1 let generation = self.fetchGeneration self.fetchTask = Task { @MainActor [weak self] in await self?.performFetch(generation: generation) } } } func toggleDevice( _ device: AnyDevice, state: DeviceState, completion: @escaping (Error?) -> Void ) { let entityID = device.serial let deviceType = device.type Task { @MainActor [weak self] in guard let self else { completion(HomeAssistantError.disconnected) return } do { try self.startConnectionIfNecessary() try await self.waitUntilConnected() let service: CallServiceMessage.Service = state == .on ? .turnOn : .turnOff let domain: CallServiceMessage.ServiceDomain = deviceType == .light || entityID.hasPrefix("light.") ? .light : .switch let message = CallServiceMessage( entityID: entityID, service: service, domain: domain ) _ = try await self.liaison.sendCommand(message) completion(nil) } catch { completion(error) } } } func responsibleForDevice(_ device: AnyDevice) -> Bool { dispatchPrecondition(condition: .onQueue(.main)) return knownDevices.contains { $0.serial == device.serial } } #if DEBUG func simulateConnectionFailureForTesting() { dispatchPrecondition(condition: .onQueue(.main)) Task { await liaison.invalidate(with: HomeAssistantError.websocketClosed) } } #endif // MARK: - Connection lifecycle private func startConnectionIfNecessary() throws { guard let authToken, !authToken.isEmpty else { throw HomeAssistantError.missingAccessToken } wantsConnection = true guard connectionTask == nil else { return } connectionGeneration += 1 let generation = connectionGeneration setConnectionStatus(.connecting) connectionTask = Task { @MainActor [weak self] in await self?.runConnectionLoop(authToken: authToken, generation: generation) } } @MainActor private func runConnectionLoop(authToken: String, generation: Int) async { var retryAttempt = 0 while isCurrentConnection(generation) { setConnectionStatus(.connecting) do { let events = await liaison.open(generation: generation) var authenticated = false connectionEvents: for await event in events { try Task.checkCancellation() guard isCurrentConnection(generation) else { break connectionEvents } switch event { case .authRequired: try await liaison.sendAuthentication(token: authToken) case .authOK: await liaison.markAuthenticated() _ = try await liaison.sendCommand(SubscribeEventsMessage()) authenticated = true retryAttempt = 0 didConnect(generation: generation) case .authInvalid(let message): throw HomeAssistantError.authenticationFailed(message) case .subscription(let event): guard authenticated else { continue } handleSubscriptionEvent(event) case .unhandled(let type): #if DEBUG print("[HomeAssistant] Ignoring websocket message type '\(type)'") #endif case .disconnected(let error): throw error } } if isCurrentConnection(generation) { throw HomeAssistantError.websocketClosed } } catch is CancellationError { break } catch { stopHeartbeat() await liaison.disconnect(generation: generation) guard isCurrentConnection(generation) else { break } setConnectionStatus(.error) print("[HomeAssistant] Connection failed: \(error.localizedDescription)") if case HomeAssistantError.authenticationFailed = error { wantsConnection = false break } let delay = Self.reconnectDelays[min(retryAttempt, Self.reconnectDelays.count - 1)] retryAttempt += 1 do { try await Task.sleep(nanoseconds: Self.nanoseconds(delay)) } catch { break } } } stopHeartbeat() await liaison.disconnect(generation: generation) if connectionGeneration == generation { connectionTask = nil if !wantsConnection, connectionStatus != .error { setConnectionStatus(.disconnected) } } } @MainActor private func didConnect(generation: Int) { guard isCurrentConnection(generation) else { return } setConnectionStatus(.connected) print("[HomeAssistant] Connected and subscribed") startHeartbeat(generation: generation) } @MainActor private func startHeartbeat(generation: Int) { stopHeartbeat() heartbeatTask = Task { @MainActor [weak self] in guard let self else { return } while self.isCurrentConnection(generation) { do { try await Task.sleep( nanoseconds: Self.nanoseconds(Self.heartbeatInterval) ) try Task.checkCancellation() guard self.connectionStatus == .connected else { continue } try await self.liaison.ping() #if DEBUG print("[HomeAssistant] Heartbeat acknowledged") #endif } catch is CancellationError { return } catch { guard self.isCurrentConnection(generation) else { return } print("[HomeAssistant] Heartbeat failed: \(error.localizedDescription)") await self.liaison.invalidate(with: error) return } } } } private func stopHeartbeat() { heartbeatTask?.cancel() heartbeatTask = nil } @MainActor private func waitUntilConnected() async throws { let deadline = Date().addingTimeInterval(Self.connectionTimeout) while wantsConnection, Date() < deadline { switch connectionStatus { case .connected: return case .disconnected: throw HomeAssistantError.disconnected case .connecting, .error: break } try await Task.sleep(nanoseconds: Self.nanoseconds(0.1)) } if !wantsConnection { throw HomeAssistantError.disconnected } throw HomeAssistantError.connectionTimedOut } private func isCurrentConnection(_ generation: Int) -> Bool { wantsConnection && connectionGeneration == generation && !Task.isCancelled } private func setConnectionStatus(_ status: ConnectionStatus) { guard connectionStatus != status else { return } connectionStatus = status delegate?.server(self, connectionStatusChanged: status) } // MARK: - Devices @MainActor private func performFetch(generation: Int) async { let result: Result<[AnyDevice], Error> do { try startConnectionIfNecessary() try await waitUntilConnected() let devices = try await loadDevices() knownDevices = Set(devices) result = .success(devices.map { $0.eraseToAnyDevice() }) } catch { result = .failure(error) } guard fetchGeneration == generation else { return } fetchTask = nil let completions = pendingFetchCompletions pendingFetchCompletions.removeAll() completions.forEach { $0(result) } } private func loadDevices() async throws -> [HomeAssistantDevice] { let registryData = try await liaison.sendCommand(RegistryEntriesMessage()) let registryEntries: [RegistryEntriesMessage.Entry] = try Self.decodeResult( [RegistryEntriesMessage.Entry].self, from: registryData ) let statesData = try await liaison.sendCommand(GetStatesMessage()) let states: [State] = try Self.decodeResult([State].self, from: statesData) let statesByEntityID = Dictionary( states.map { ($0.entityID, $0) }, uniquingKeysWith: { _, newest in newest } ) return registryEntries .filter { $0.labels.contains(Self.filterLabel) } .map { entry in HomeAssistantDevice( entity: entry, state: statesByEntityID[entry.entityID], lightLabel: Self.lightLabel ) } } private func handleSubscriptionEvent(_ event: SubscriptionEvent) { guard let newState = event.data.newState?.state.deviceState, let device = knownDevices.first(where: { $0.serial == event.data.entityID }) else { return } device.state = newState delegate?.server(self, deviceChangedState: device.eraseToAnyDevice()) } private static func decodeResult( _ type: T.Type, from data: Data ) throws -> T { let response = try JSONDecoder().decode(ResultEnvelope.self, from: data) return response.result } private static func configuredAccessToken() -> String? { let environmentToken = ProcessInfo.processInfo.environment[ "HOME_ASSISTANT_ACCESS_TOKEN" ] let bundledToken = Bundle.main.object( forInfoDictionaryKey: "HomeAssistantAccessToken" ) as? String return [environmentToken, bundledToken] .compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) } .first { !$0.isEmpty && !$0.hasPrefix("$(") } } private static func nanoseconds(_ seconds: TimeInterval) -> UInt64 { UInt64(max(0, seconds) * 1_000_000_000) } // MARK: - Websocket liaison private actor Liaison { enum Event { case authRequired case authOK case authInvalid(String) case subscription(SubscriptionEvent) case unhandled(String) case disconnected(Error) } private struct PendingRequest { let connectionID: UUID let continuation: CheckedContinuation var timeoutTask: Task? } private static let requestTimeout: TimeInterval = 10 private static let authenticationTimeout: TimeInterval = 15 private let websocketURL: URL private let session: URLSession private var websocketTask: URLSessionWebSocketTask? private var connectionID: UUID? private var latestGeneration = 0 private var eventContinuation: AsyncStream.Continuation? private var receiveTask: Task? private var authenticationTimeoutTask: Task? private var pendingRequests: [Int64: PendingRequest] = [:] private var nextMessageID: Int64 = 1 fileprivate init(websocketURL: URL) { self.websocketURL = websocketURL let configuration = URLSessionConfiguration.ephemeral configuration.waitsForConnectivity = true configuration.timeoutIntervalForRequest = 30 self.session = URLSession(configuration: configuration) } fileprivate func open(generation: Int) -> AsyncStream { guard generation >= latestGeneration else { return AsyncStream { $0.finish() } } latestGeneration = generation closeCurrentConnection(error: HomeAssistantError.disconnected) let newConnectionID = UUID() var streamContinuation: AsyncStream.Continuation? let stream = AsyncStream(bufferingPolicy: .bufferingNewest(100)) { streamContinuation = $0 } let websocketTask = session.webSocketTask(with: websocketURL) websocketTask.maximumMessageSize = 16 * 1024 * 1024 self.connectionID = newConnectionID self.eventContinuation = streamContinuation self.websocketTask = websocketTask websocketTask.resume() receiveTask = Task { [weak self] in await self?.receiveMessages( from: websocketTask, connectionID: newConnectionID ) } authenticationTimeoutTask = Task { [weak self] in do { try await Task.sleep( nanoseconds: HomeAssistantServer.nanoseconds( Self.authenticationTimeout ) ) } catch { return } await self?.failConnection( HomeAssistantError.authenticationTimedOut, connectionID: newConnectionID ) } return stream } fileprivate func disconnect(generation: Int) { guard generation >= latestGeneration else { return } latestGeneration = generation closeCurrentConnection(error: HomeAssistantError.disconnected) } fileprivate func invalidate(with error: Error) { guard let connectionID else { return } failConnection(error, connectionID: connectionID) } fileprivate func markAuthenticated() { authenticationTimeoutTask?.cancel() authenticationTimeoutTask = nil } fileprivate func sendAuthentication(token: String) async throws { guard let websocketTask, let connectionID else { throw HomeAssistantError.disconnected } let data = try JSONEncoder().encode(AuthMessage(token: token)) guard let json = String(data: data, encoding: .utf8) else { throw HomeAssistantError.invalidResponse } do { try await websocketTask.send(.string(json)) } catch { failConnection(error, connectionID: connectionID) throw error } } @discardableResult fileprivate func sendCommand(_ message: M) async throws -> Data { let responseData = try await sendAndWait(message) let response = try JSONDecoder().decode(CommandResponse.self, from: responseData) guard response.type == "result", let success = response.success else { throw HomeAssistantError.invalidResponse } guard success else { throw response.error ?? HomeAssistantError.commandFailed } return responseData } fileprivate func ping() async throws { let responseData = try await sendAndWait(PingMessage()) let response = try JSONDecoder().decode(BasicEnvelope.self, from: responseData) guard response.type == "pong" else { throw HomeAssistantError.invalidResponse } } private func sendAndWait(_ originalMessage: M) async throws -> Data { guard let websocketTask, let connectionID else { throw HomeAssistantError.disconnected } var message = originalMessage let messageID = nextMessageID nextMessageID += 1 message.id = messageID let encodedMessage = try JSONEncoder().encode(message) guard let json = String(data: encodedMessage, encoding: .utf8) else { throw HomeAssistantError.invalidResponse } return try await withTaskCancellationHandler { try await withCheckedThrowingContinuation { continuation in pendingRequests[messageID] = PendingRequest( connectionID: connectionID, continuation: continuation, timeoutTask: nil ) let timeoutTask = Task { [weak self] in do { try await Task.sleep( nanoseconds: HomeAssistantServer.nanoseconds( Self.requestTimeout ) ) } catch { return } await self?.finishRequest( id: messageID, connectionID: connectionID, result: .failure(HomeAssistantError.requestTimedOut) ) } pendingRequests[messageID]?.timeoutTask = timeoutTask Task { [weak self] in do { try await websocketTask.send(.string(json)) } catch { await self?.sendFailed( error, requestID: messageID, connectionID: connectionID ) } } } } onCancel: { Task { [weak self] in await self?.finishRequest( id: messageID, connectionID: connectionID, result: .failure(CancellationError()) ) } } } private func receiveMessages( from websocketTask: URLSessionWebSocketTask, connectionID: UUID ) async { while !Task.isCancelled { do { let message = try await websocketTask.receive() guard self.connectionID == connectionID else { return } switch message { case .string(let string): guard let data = string.data(using: .utf8) else { continue } handleIncoming(data, connectionID: connectionID) case .data(let data): handleIncoming(data, connectionID: connectionID) @unknown default: continue } } catch is CancellationError { return } catch { failConnection(error, connectionID: connectionID) return } } } private func handleIncoming(_ data: Data, connectionID: UUID) { do { let envelope = try JSONDecoder().decode(IncomingEnvelope.self, from: data) if let messageID = envelope.id, envelope.type == "result" || envelope.type == "pong" { finishRequest( id: messageID, connectionID: connectionID, result: .success(data) ) return } switch envelope.type { case "auth_required": eventContinuation?.yield(.authRequired) case "auth_ok": eventContinuation?.yield(.authOK) case "auth_invalid": eventContinuation?.yield( .authInvalid(envelope.message ?? "Authentication failed") ) case "event": if let event = envelope.event { eventContinuation?.yield(.subscription(event)) } default: eventContinuation?.yield(.unhandled(envelope.type)) } } catch { // Future Home Assistant messages should not tear down an otherwise healthy socket. print("[HomeAssistant] Could not decode websocket message: \(error)") } } private func sendFailed( _ error: Error, requestID: Int64, connectionID: UUID ) { finishRequest( id: requestID, connectionID: connectionID, result: .failure(error) ) failConnection(error, connectionID: connectionID) } private func finishRequest( id: Int64, connectionID: UUID, result: Result ) { guard let pendingRequest = pendingRequests[id], pendingRequest.connectionID == connectionID else { return } pendingRequests.removeValue(forKey: id) pendingRequest.timeoutTask?.cancel() pendingRequest.continuation.resume(with: result) } private func failConnection(_ error: Error, connectionID: UUID) { guard self.connectionID == connectionID else { return } eventContinuation?.yield(.disconnected(error)) closeCurrentConnection(error: error) } private func closeCurrentConnection(error: Error) { authenticationTimeoutTask?.cancel() authenticationTimeoutTask = nil receiveTask?.cancel() receiveTask = nil websocketTask?.cancel(with: .goingAway, reason: nil) websocketTask = nil connectionID = nil eventContinuation?.finish() eventContinuation = nil let requests = pendingRequests.values pendingRequests.removeAll() requests.forEach { $0.timeoutTask?.cancel() $0.continuation.resume(throwing: error) } } } } // MARK: - Errors and wire types private enum HomeAssistantError: LocalizedError { case missingAccessToken case disconnected case websocketClosed case authenticationTimedOut case authenticationFailed(String) case connectionTimedOut case requestTimedOut case commandFailed case invalidResponse var errorDescription: String? { switch self { case .missingAccessToken: return "No Home Assistant access token is configured." case .disconnected: return "The Home Assistant connection is offline." case .websocketClosed: return "The Home Assistant websocket closed." case .authenticationTimedOut: return "Home Assistant authentication timed out." case .authenticationFailed(let message): return "Home Assistant authentication failed: \(message)" case .connectionTimedOut: return "Timed out waiting for Home Assistant to connect." case .requestTimedOut: return "A Home Assistant request timed out." case .commandFailed: return "Home Assistant rejected the command." case .invalidResponse: return "Home Assistant returned an invalid response." } } } private struct ErrorResult: Decodable, LocalizedError { let code: String let message: String var errorDescription: String? { "\(message) (\(code))" } } private struct BasicEnvelope: Decodable { let id: Int64? let type: String } private struct IncomingEnvelope: Decodable { let id: Int64? let type: String let event: SubscriptionEvent? let message: String? } private struct CommandResponse: Decodable { let type: String let success: Bool? let error: ErrorResult? } private struct ResultEnvelope: Decodable { let result: Result } private struct SubscriptionEvent: Decodable { let eventType: String let data: SubscriptionEventData 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 } } private struct State: Decodable { let entityID: String let state: Value let attributes: Attributes enum Value: Decodable { case on case off case other(String) init(from decoder: Decoder) throws { let container = try decoder.singleValueContainer() let value = try container.decode(String.self) switch value.lowercased() { case "on": self = .on case "off": self = .off default: self = .other(value) } } var deviceState: DeviceState? { switch self { case .on: return .on case .off: return .off case .other: return nil } } } struct Attributes: Decodable { let friendlyName: String? enum CodingKeys: String, CodingKey { case friendlyName = "friendly_name" } } enum CodingKeys: String, CodingKey { case entityID = "entity_id" case state case attributes } } private extension HomeAssistantDevice { convenience init( entity: RegistryEntriesMessage.Entry, state: State?, lightLabel: String ) { let type: DeviceType = entity.labels.contains(lightLabel) ? .light : .switch let name = entity.name ?? state?.attributes.friendlyName?.replacingOccurrences(of: " Switch", with: "") ?? entity.entityID self.init( name: name.replacingOccurrences(of: " Switch", with: ""), serial: entity.entityID, type: type, state: state?.state.deviceState ?? .off ) } } // MARK: - Outgoing messages private protocol CommandMessage: Encodable { var id: Int64 { get set } var type: String { get } } private struct AuthMessage: Encodable { let type = "auth" let token: String enum CodingKeys: String, CodingKey { case type case token = "access_token" } } private struct RegistryEntriesMessage: CommandMessage { var id: Int64 = 0 let type = "config/entity_registry/list" struct Entry: Decodable { let entityID: String let name: String? let labels: [String] enum CodingKeys: String, CodingKey { case name case labels case entityID = "entity_id" } init(from decoder: Decoder) throws { let values = try decoder.container(keyedBy: CodingKeys.self) entityID = try values.decode(String.self, forKey: .entityID) name = try values.decodeIfPresent(String.self, forKey: .name) labels = try values.decodeIfPresent([String].self, forKey: .labels) ?? [] } } } private struct GetStatesMessage: CommandMessage { var id: Int64 = 0 let type = "get_states" } private struct SubscribeEventsMessage: CommandMessage { var id: Int64 = 0 let type = "subscribe_events" let eventType = "state_changed" enum CodingKeys: String, CodingKey { case id case type case eventType = "event_type" } } private struct PingMessage: CommandMessage { var id: Int64 = 0 let type = "ping" } private struct CallServiceMessage: CommandMessage { var id: Int64 = 0 let type = "call_service" let service: Service let domain: ServiceDomain let target: Target init(entityID: String, service: Service, domain: ServiceDomain) { self.service = service self.domain = domain self.target = Target(entityID: entityID) } enum ServiceDomain: String, Codable { case `switch` case light } enum Service: String, Codable { case turnOn = "turn_on" case turnOff = "turn_off" } struct Target: Codable { let entityID: String enum CodingKeys: String, CodingKey { case entityID = "entity_id" } } }