Files
Xaibatsu-Control-Panel/XIONControlPanel/Servers/HomeAssistantServer.swift

691 lines
20 KiB
Swift
Raw Normal View History

//
// HomeAssistantServer.swift
// XIONControlPanel
//
// Created by James Magahern on 6/18/25.
// Copyright © 2025 XION. All rights reserved.
//
2025-06-19 19:25:53 -07:00
import Combine
import Foundation
class HomeAssistantServer: Server
{
var connectionStatus: ConnectionStatus = .disconnected
2025-06-19 20:43:05 -07:00
weak var delegate: ServerDelegate?
// Configure:
private let authToken: String = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiI2ZTAwODI1N2Y4N2Q0OWIxYWRkN2ExNTBhOWRmNGZiZCIsImlhdCI6MTc1MDMwMDg5NiwiZXhwIjoyMDY1NjYwODk2fQ.KRf9GpRZdpW9Z_vkbL3sl74rgKS7eAyc8kO0a5jLTgg"
// Configure:
private static let filterLabel = "control_panel"
private let url: URL
2025-06-19 19:25:53 -07:00
private let liaison: Liaison
private var connectionContinuations: [CheckedContinuation<Bool, Never>] = []
2025-06-19 19:25:53 -07:00
private var eventTask: Task<(), any Error>? = nil
2025-06-19 20:43:05 -07:00
private var knownDevices: Set<HomeAssistantDevice> = .init()
2025-06-19 19:25:53 -07:00
typealias NoResult = [String: String]
required init(_ url: URL) {
self.url = url
2025-06-19 19:25:53 -07:00
let websocketURL = url.appending(component: "websocket").websocket()
self.liaison = Liaison(websocketURL: websocketURL)
}
func connect(_ completion: @escaping ((any Error)?) -> Void) {
2025-06-19 19:25:53 -07:00
guard self.eventTask == nil else {
completion(nil)
return
}
2025-06-19 19:25:53 -07:00
self.eventTask = Task {
for await event in try await liaison.partialEvents() {
do {
try await handleLiaisonEvent(event)
} catch {
encounteredWebsocketError(error)
}
}
2025-06-19 19:25:53 -07:00
print("Event loop exited")
connectionStatus = .error
}
completion(nil)
}
func disconnect(_ completion: ((any Error)?) -> Void) {
2025-06-24 17:38:55 -07:00
self.eventTask?.cancel()
self.eventTask = nil
2025-06-19 19:25:53 -07:00
Task { await liaison.disconnect() }
completion(nil)
}
func fetchDevices(_ completion: @escaping (Result<[AnyDevice], any Error>) -> Void) {
2025-06-19 19:25:53 -07:00
guard connectionStatus != .error else {
// Need to report this state to the UI.
completion(.failure(ServerError.disconnected))
return
}
Task {
await waitConnected()
do {
2025-06-19 20:43:05 -07:00
guard case let .result(registryEntries) = try await liaison.sendMessage(RegistryEntriesMessage())
else { fatalError() }
let filteredEntries = registryEntries.filter { $0.labels.contains(Self.filterLabel) }
2025-06-19 20:43:05 -07:00
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)
}
2025-06-19 20:43:05 -07:00
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) {
2025-06-19 19:25:53 -07:00
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
}
2025-06-19 19:25:53 -07:00
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
)
2025-06-19 20:43:05 -07:00
try await liaison.sendMessage(message)
Task { @MainActor in completion(nil) }
} catch {
Task { @MainActor in completion(error) }
}
}
}
func responsibleForDevice(_ device: AnyDevice) -> Bool {
2025-06-19 20:43:05 -07:00
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)
}
2025-06-19 19:25:53 -07:00
} else if connectionStatus == .error {
return false
}
return true
}
2025-06-19 15:00:19 -07:00
private func encounteredWebsocketError(_ error: any Error) {
print("Websocket Error: \(error)")
self.connectionStatus = .error
2025-06-19 19:25:53 -07:00
self.eventTask?.cancel()
self.eventTask = nil
}
2025-06-19 19:25:53 -07:00
private func handleLiaisonEvent(_ event: Liaison.Event) async throws {
switch event {
case .partialEvent(let partialEvent, _):
switch partialEvent.type {
case .authRequired:
2025-06-19 19:25:53 -07:00
Task { await authenticate() }
case .authOK:
2025-06-19 20:43:05 -07:00
Task {
await subscribeToStateEvents()
didConnectToWebsocket()
}
case .event:
if let subEvent = partialEvent.event {
handleSubscriptionEvent(subEvent)
}
2025-06-19 19:25:53 -07:00
default:
2025-06-19 20:43:05 -07:00
print("Unhandled event: \(partialEvent)")
2025-06-19 19:25:53 -07:00
break
}
2025-06-19 19:25:53 -07:00
case .pong:
didConnectToWebsocket()
case .disconnected(let error):
encounteredWebsocketError(error)
case .error(let error):
encounteredWebsocketError(error)
}
}
2025-06-19 20:43:05 -07:00
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
2025-06-24 17:38:55 -07:00
case .other(_): .off
2025-06-19 20:43:05 -07:00
}
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 {
2025-06-19 20:43:05 -07:00
try await liaison.sendMessage(AuthMessage(token: authToken))
} catch {
print("Error authenticating: \(error)")
}
}
2025-06-19 19:25:53 -07:00
// MARK: - Liaison
actor Liaison
{
let websocketURL: URL
private typealias Continuation = AsyncStream<Event>.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
2025-06-19 20:43:05 -07:00
fileprivate func sendMessage<M: Message>(_ message: M) async throws -> MessageResult<M.Response> {
2025-06-19 19:25:53 -07:00
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 {
2025-06-19 20:43:05 -07:00
let decodedResponse = try JSONDecoder().decode(HomeAssistantServer.Event<M.Response>.self, from: response)
2025-06-19 19:25:53 -07:00
if let result = decodedResponse.result {
2025-06-19 20:43:05 -07:00
continuation.resume(returning: .result(result))
} else if let success = decodedResponse.success {
continuation.resume(with: .success(.success(success)))
} else {
2025-06-19 19:25:53 -07:00
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<Liaison.Event> {
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)!
2025-06-19 20:43:05 -07:00
await handlePartialEvent(data: data)
2025-06-19 19:25:53 -07:00
case .data(let data):
2025-06-19 20:43:05 -07:00
await handlePartialEvent(data: data)
2025-06-19 19:25:53 -07:00
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)
}
}
}
}
}
}
2025-06-19 20:43:05 -07:00
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)")
}
2025-06-19 19:25:53 -07:00
} else {
2025-06-19 20:43:05 -07:00
yield(.partialEvent(event, data))
2025-06-19 19:25:53 -07:00
}
2025-06-19 20:43:05 -07:00
} 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)")
2025-06-19 19:25:53 -07:00
}
}
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
2025-06-19 20:43:05 -07:00
struct PartialEvent: Decodable
{
let id: Int64?
let type: EventType
2025-06-19 20:43:05 -07:00
let event: SubscriptionEvent?
}
struct Event<Result: Decodable>: Decodable
{
let id: Int?
let type: EventType
let result: Result?
2025-06-19 20:43:05 -07:00
let success: Bool?
let error: ErrorResult?
}
2025-06-19 20:43:05 -07:00
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"
2025-06-19 20:43:05 -07:00
case event = "event"
}
2025-06-19 19:25:53 -07:00
struct ErrorResult: Codable, Swift.Error
{
let code: String
let message: String
}
2025-06-19 19:25:53 -07:00
enum ServerError: Swift.Error
{
case disconnected
}
// MARK: - Result Types
}
extension HomeAssistantDevice
{
2025-06-19 20:43:05 -07:00
fileprivate convenience init(entity: RegistryEntriesMessage.Entry, state: DeviceState) {
2025-06-19 19:25:53 -07:00
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,
2025-06-19 19:25:53 -07:00
type: type,
state: state
)
}
}
typealias MessageID = Int64?
extension MessageID
{
2025-06-19 15:00:19 -07:00
private static var counter: Int64 = 1
static func next() -> Self {
2025-06-19 15:00:19 -07:00
OSAtomicIncrement64(&Self.counter)
return Self.counter
}
}
2025-06-19 20:43:05 -07:00
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
2025-06-19 20:43:05 -07:00
typealias Null = String?
enum MessageResult<R>
{
case result(R)
case success(Bool)
}
fileprivate protocol Message: Encodable
{
2025-06-19 20:43:05 -07:00
associatedtype Response: Decodable
2025-06-19 19:25:53 -07:00
var id: MessageID { get set }
var type: String { get }
2025-06-19 19:25:53 -07:00
func nextMessageID() -> MessageID
}
extension Message
{
func nextMessageID() -> MessageID { .next() }
}
fileprivate struct RegistryEntriesMessage: Message
{
2025-06-19 19:25:53 -07:00
var id: MessageID = nil
let type: String = "config/entity_registry/list"
2025-06-19 20:43:05 -07:00
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
{
2025-06-19 19:25:53 -07:00
var id: MessageID = nil
let type: String = "get_states"
2025-06-19 20:43:05 -07:00
typealias Response = [State]
}
fileprivate struct AuthMessage: Message
{
2025-06-19 19:25:53 -07:00
var id: MessageID = nil
let type: String = "auth"
let token: String
2025-06-19 19:25:53 -07:00
// No ID for auth messages
func nextMessageID() -> MessageID { nil }
enum CodingKeys: String, CodingKey {
case id
case type
case token = "access_token"
}
2025-06-19 20:43:05 -07:00
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
{
2025-06-19 19:25:53 -07:00
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`
2025-06-19 19:25:53 -07:00
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"
}
}
2025-06-19 20:43:05 -07:00
typealias Response = Null
}