diff --git a/XIONControlPanel.xcodeproj/project.pbxproj b/XIONControlPanel.xcodeproj/project.pbxproj index e340dcd..5e9d474 100644 --- a/XIONControlPanel.xcodeproj/project.pbxproj +++ b/XIONControlPanel.xcodeproj/project.pbxproj @@ -41,6 +41,8 @@ CD815B92241DA28B00E2FA97 /* ServerMultiplex.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD815B91241DA28B00E2FA97 /* ServerMultiplex.swift */; }; CD815B94241DC16700E2FA97 /* HubitatServer.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD815B93241DC16700E2FA97 /* HubitatServer.swift */; }; CD815B96241DC44D00E2FA97 /* HubitatDevice.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD815B95241DC44D00E2FA97 /* HubitatDevice.swift */; }; + CDA64C242E03AE6400E9B07E /* HomeAssistantServer.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDA64C232E03AE6100E9B07E /* HomeAssistantServer.swift */; }; + CDA64C262E03EA1400E9B07E /* HomeAssistantDevice.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDA64C252E03EA1000E9B07E /* HomeAssistantDevice.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -83,6 +85,8 @@ CD815B91241DA28B00E2FA97 /* ServerMultiplex.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerMultiplex.swift; sourceTree = ""; }; CD815B93241DC16700E2FA97 /* HubitatServer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HubitatServer.swift; sourceTree = ""; }; CD815B95241DC44D00E2FA97 /* HubitatDevice.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HubitatDevice.swift; sourceTree = ""; }; + CDA64C232E03AE6100E9B07E /* HomeAssistantServer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeAssistantServer.swift; sourceTree = ""; }; + CDA64C252E03EA1000E9B07E /* HomeAssistantDevice.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeAssistantDevice.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -131,6 +135,7 @@ children = ( CD815B8F241C7CAC00E2FA97 /* DeviceProtocol.swift */, CD815B95241DC44D00E2FA97 /* HubitatDevice.swift */, + CDA64C252E03EA1000E9B07E /* HomeAssistantDevice.swift */, 0C4314FF1C35B1E700164559 /* WemoDevice.swift */, ); path = Models; @@ -232,6 +237,7 @@ children = ( CD815B91241DA28B00E2FA97 /* ServerMultiplex.swift */, CD815B8D241C6F4000E2FA97 /* ServerProtocol.swift */, + CDA64C232E03AE6100E9B07E /* HomeAssistantServer.swift */, CD815B93241DC16700E2FA97 /* HubitatServer.swift */, 0CAA32781C361A2400B353CF /* WemoServer.swift */, ); @@ -324,10 +330,12 @@ files = ( 0C41EF4E1C34B81F00AF847F /* KSIRingView.m in Sources */, 0CAA327B1C361CBE00B353CF /* NSErrorAdditions.swift in Sources */, + CDA64C242E03AE6400E9B07E /* HomeAssistantServer.swift in Sources */, 0CF0F8961C362D8F00FE7752 /* NSURLAdditions.swift in Sources */, CD815B94241DC16700E2FA97 /* HubitatServer.swift in Sources */, 0CAA32791C361A2400B353CF /* WemoServer.swift in Sources */, 0C41EF561C35ABCD00AF847F /* ConnectionStatusView.swift in Sources */, + CDA64C262E03EA1400E9B07E /* HomeAssistantDevice.swift in Sources */, 0C63A87D1C33C655007E4B52 /* NSValue+XIONAdditions.m in Sources */, CD815B96241DC44D00E2FA97 /* HubitatDevice.swift in Sources */, 0C20E2BD1C33534F00BA789B /* VisualizationViewController.swift in Sources */, diff --git a/XIONControlPanel/Controllers/MainViewController.swift b/XIONControlPanel/Controllers/MainViewController.swift index eaa8b8c..d37de45 100644 --- a/XIONControlPanel/Controllers/MainViewController.swift +++ b/XIONControlPanel/Controllers/MainViewController.swift @@ -21,8 +21,9 @@ class MainViewController: UIViewController, SwitchesViewControllerDelegate, Serv super.init(nibName: nibNameOrNil, bundle: nibBundleOrNil) _serverMultiplex.delegate = self - _serverMultiplex.addServer(HubitatServer(URL(string: "https://midna.xionsf.com:6000/apps/api/1/")!)) - _serverMultiplex.addServer(WemoServer(URL(string: "http://midna.xionsf.com:5000")!)) + _serverMultiplex.addServer(HomeAssistantServer(URL(string: "https://ha.xaibatsu.com/api")!)) + // _serverMultiplex.addServer(HubitatServer(URL(string: "https://midna.xionsf.com:6000/apps/api/1/")!)) + // _serverMultiplex.addServer(WemoServer(URL(string: "http://midna.xionsf.com:5000")!)) } required init?(coder aDecoder: NSCoder) diff --git a/XIONControlPanel/Controllers/SwitchesViewController.swift b/XIONControlPanel/Controllers/SwitchesViewController.swift index b831789..89d4d17 100644 --- a/XIONControlPanel/Controllers/SwitchesViewController.swift +++ b/XIONControlPanel/Controllers/SwitchesViewController.swift @@ -124,6 +124,7 @@ class SwitchesViewController: UIViewController, var snapshot = _collectionViewDataSource.snapshot() snapshot.deleteItems(snapshot.itemIdentifiers(inSection: SwitchesViewController.switchCellsSectionIdentifier)) snapshot.appendItems(self.devices.map { $0.hashValue }, toSection: SwitchesViewController.switchCellsSectionIdentifier) + snapshot.reloadSections([Self.actionCellsSectionIdentifier]) _collectionViewDataSource.apply(snapshot, animatingDifferences: false) } } diff --git a/XIONControlPanel/Models/DeviceProtocol.swift b/XIONControlPanel/Models/DeviceProtocol.swift index 15db6a6..a7bf0d7 100644 --- a/XIONControlPanel/Models/DeviceProtocol.swift +++ b/XIONControlPanel/Models/DeviceProtocol.swift @@ -70,3 +70,9 @@ class AnyDevice : Device } extension AnyDevice : Hashable {} + +extension Device { + func eraseToAnyDevice() -> AnyDevice { + AnyDevice(self) + } +} diff --git a/XIONControlPanel/Models/HomeAssistantDevice.swift b/XIONControlPanel/Models/HomeAssistantDevice.swift new file mode 100644 index 0000000..b3b2cf2 --- /dev/null +++ b/XIONControlPanel/Models/HomeAssistantDevice.swift @@ -0,0 +1,24 @@ +// +// HomeAssistantDevice.swift +// XIONControlPanel +// +// Created by James Magahern on 6/18/25. +// Copyright © 2025 XION. All rights reserved. +// + +import Foundation + +class HomeAssistantDevice: Device +{ + var name: String + var serial: String + var type: DeviceType + var state: DeviceState + + init(name: String, serial: String, type: DeviceType, state: DeviceState) { + self.name = name + self.serial = serial + self.type = type + self.state = state + } +} diff --git a/XIONControlPanel/Servers/HomeAssistantServer.swift b/XIONControlPanel/Servers/HomeAssistantServer.swift new file mode 100644 index 0000000..753d798 --- /dev/null +++ b/XIONControlPanel/Servers/HomeAssistantServer.swift @@ -0,0 +1,389 @@ +// +// HomeAssistantServer.swift +// XIONControlPanel +// +// Created by James Magahern on 6/18/25. +// Copyright © 2025 XION. All rights reserved. +// + +import Foundation + +class HomeAssistantServer: Server +{ + var connectionStatus: ConnectionStatus = .disconnected + + private typealias Reply = (Data) -> Void + + // Configure: + private let authToken: String = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiI2ZTAwODI1N2Y4N2Q0OWIxYWRkN2ExNTBhOWRmNGZiZCIsImlhdCI6MTc1MDMwMDg5NiwiZXhwIjoyMDY1NjYwODk2fQ.KRf9GpRZdpW9Z_vkbL3sl74rgKS7eAyc8kO0a5jLTgg" + + // Configure: + private static let filterLabel = "control_panel" + + private let url: URL + private var websocketTask: URLSessionWebSocketTask? = nil + private var pendingMessages: [MessageID: Reply] = [:] + private var connectionContinuations: [CheckedContinuation] = [] + + required init(_ url: URL) { + self.url = url + } + + func connect(_ completion: @escaping ((any Error)?) -> Void) { + guard websocketTask == nil else { return } + + let url = url.appending(component: "websocket").websocket() + let websocketTask = URLSession.shared.webSocketTask(with: url) + websocketTask.resume() + + self.websocketTask = websocketTask + Task { + do { + while websocketTask.state == .running { + switch try await websocketTask.receive() { + case .string(let string): + await decodeEvent(string.data(using: .utf8)!) + case .data(let data): + await decodeEvent(data) + default: + break + } + } + + print("Websocket not running: \(String(describing: websocketTask.error))") + } catch { + print("Websocket Error: \(error)") + self.connectionStatus = .error + } + } + + completion(nil) + } + + func disconnect(_ completion: ((any Error)?) -> Void) { + websocketTask?.cancel() + websocketTask = nil + completion(nil) + } + + func fetchDevices(_ completion: @escaping (Result<[AnyDevice], any Error>) -> Void) { + Task { + await waitConnected() + + do { + let registryEntries: [RegistryEntry] = try await sendMessage(RegistryEntriesMessage()) + let filteredEntries = registryEntries.filter { $0.labels.contains(Self.filterLabel) } + + let states: [EntityState] = try await sendMessage(GetStatesMessage()) + 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) + .eraseToAnyDevice() + } + + Task { @MainActor in completion(.success(devices)) } + } catch { + Task { @MainActor in completion(.failure(error)) } + } + } + } + + func toggleDevice(_ device: AnyDevice, state: DeviceState, completion: @escaping ((any Error)?) -> Void) { + Task { + await waitConnected() + + do { + let entityID = device.serial + let service: CallServiceMessage.Service = switch state { + case .off: .turnOff + case .on: .turnOn + } + + let _: Bool = try await sendMessage(CallServiceMessage(entityID: entityID, service: service)) + Task { @MainActor in completion(nil) } + } catch { + Task { @MainActor in completion(error) } + } + } + } + + func responsibleForDevice(_ device: AnyDevice) -> Bool { + return true + } + + // MARK: - + + @discardableResult + private func waitConnected() async -> Bool { + if connectionStatus == .connecting || connectionStatus == .disconnected { + return await withCheckedContinuation { continuation in + self.connectionContinuations.append(continuation) + } + } + + return true + } + + @discardableResult + private func sendMessage(_ message: Message) async throws -> R { + return try await withCheckedThrowingContinuation { continuation in + Task { @MainActor in + self.pendingMessages[message.id] = { response in + do { + let decodedResponse = try JSONDecoder().decode(Event.self, from: response) + if let result = decodedResponse.result { + continuation.resume(returning: result) + } 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: message.id) + } + } + + do { + let encodedMessage = try JSONEncoder().encode(message) + let jsonString = String(data: encodedMessage, encoding: .utf8)! + websocketTask?.send(.string(jsonString)) { error in + guard let error else { return } + print("Websocket send error: \(error)") + } + } catch { + print("Error sending message: \(error)") + } + } + } + + private func decodeEvent(_ data: Data) async { + do { + let decoder = JSONDecoder() + let partialEvent = try decoder.decode(PartialEvent.self, from: data) + try await handlePartialEvent(partialEvent, data: data) + } catch { + print("HA Decoding error: \(error)") + } + } + + private func handlePartialEvent(_ partialEvent: PartialEvent, data: Data) async throws { + Task { + switch partialEvent.type { + case .authRequired: + await authenticate() + case .authOK: + didConnectToWebsocket() + case .result: + guard let id = partialEvent.id else { return } + Task { @MainActor in + self.pendingMessages[id]?(data) + } + } + } + } + + private func didConnectToWebsocket() { + self.connectionStatus = .connected + + connectionContinuations.forEach { continuation in + continuation.resume(returning: true) + } + connectionContinuations.removeAll() + } + + private func authenticate() async { + do { + let _: Bool = try await sendMessage(AuthMessage(token: authToken)) + } catch { + print("Error authenticating: \(error)") + } + } + + // MARK: - Types + + struct PartialEvent: Codable + { + let id: Int64? + let type: EventType + } + + struct Event: Decodable + { + let id: Int? + let type: EventType + let result: Result? + let error: ErrorResult? + } + + enum EventType: String, Codable + { + case authRequired = "auth_required" + case authOK = "auth_ok" + case result = "result" + } + + struct ErrorResult: Codable, Error + { + let code: String + let message: String + } + + // MARK: - Result Types + + struct RegistryEntry: 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" + } + } + + struct EntityState: 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 + } + } +} + +extension HomeAssistantDevice +{ + convenience init(entity: HomeAssistantServer.RegistryEntry, state: DeviceState) { + self.init( + name: entity.name?.replacingOccurrences(of: " Switch", with: "") ?? entity.entityId, + serial: entity.entityId, + type: .switch, + state: state + ) + } +} + +typealias MessageID = Int64? + +extension MessageID +{ + private static var counter: Int64 = 0 + static func next() -> Self { + Self.counter += 1 + return Self.counter + } +} + +// MARK: - Message Types + +fileprivate protocol Message: Encodable +{ + var id: MessageID { get } + var type: String { get } +} + +fileprivate struct RegistryEntriesMessage: Message +{ + let id: MessageID = .next() + let type: String = "config/entity_registry/list" +} + +fileprivate struct GetStatesMessage: Message +{ + let id: MessageID = .next() + let type: String = "get_states" +} + +fileprivate struct AuthMessage: Message +{ + let id: MessageID = nil + let type: String = "auth" + let token: String + + enum CodingKeys: String, CodingKey { + case id + case type + case token = "access_token" + } +} + +fileprivate struct CallServiceMessage: Message +{ + let id: MessageID = .next() + 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` + } + + 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" + } + } +} diff --git a/XIONControlPanel/Servers/ServerMultiplex.swift b/XIONControlPanel/Servers/ServerMultiplex.swift index 90a6542..05a6238 100644 --- a/XIONControlPanel/Servers/ServerMultiplex.swift +++ b/XIONControlPanel/Servers/ServerMultiplex.swift @@ -72,6 +72,14 @@ class ServerMultiplex public func refreshDevices() { + self.servers.forEach { server in + server.connect { error in + if let error { + print("Multiplexer: error connecting server \(server): \(error)") + } + } + } + self.servers.forEach { (server: Server) in server.fetchDevices { (result: Result<[AnyDevice], Error>) in self.handleServerFetchResult(forServer: server, result: result) diff --git a/XIONControlPanel/Utilities/NSURLAdditions.swift b/XIONControlPanel/Utilities/NSURLAdditions.swift index 97f7784..16e82d8 100644 --- a/XIONControlPanel/Utilities/NSURLAdditions.swift +++ b/XIONControlPanel/Utilities/NSURLAdditions.swift @@ -53,4 +53,10 @@ extension URL return URL(string: absoluteString) } + + public func websocket() -> URL { + guard var components = URLComponents(url: self, resolvingAgainstBaseURL: false) else { fatalError() } + components.scheme = components.scheme == "https" ? "wss" : "ws" + return components.url! + } }