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

1083 lines
32 KiB
Swift
Raw Normal View History

//
// 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
2025-06-19 20:43:05 -07:00
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]
2025-06-19 19:25:53 -07:00
private let liaison: Liaison
private let authToken: String?
private var wantsConnection = false
private var connectionGeneration = 0
private var connectionTask: Task<Void, Never>?
private var heartbeatTask: Task<Void, Never>?
private var knownDevices: Set<HomeAssistantDevice> = []
private var pendingFetchCompletions: [(Result<[AnyDevice], Error>) -> Void] = []
private var fetchTask: Task<Void, Never>?
private var fetchGeneration = 0
required init(_ url: URL)
{
let websocketURL = url.appendingPathComponent("websocket").websocket()
2025-06-19 19:25:53 -07:00
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()
2025-06-19 19:25:53 -07:00
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)
}
2025-06-19 19:25:53 -07:00
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
2025-06-19 19:25:53 -07:00
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)
2025-06-19 20:43:05 -07:00
}
}
#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
}
2025-06-19 20:43:05 -07:00
}
if isCurrentConnection(generation) {
throw HomeAssistantError.websocketClosed
2025-06-19 20:43:05 -07:00
}
} catch is CancellationError {
2025-06-19 19:25:53 -07:00
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)
}
}
2025-06-19 20:43:05 -07:00
}
@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
}
}
2025-06-19 20:43:05 -07:00
}
}
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<T: Decodable>(
_ type: T.Type,
from data: Data
) throws -> T {
let response = try JSONDecoder().decode(ResultEnvelope<T>.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<Data, Error>
var timeoutTask: Task<Void, Never>?
}
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<Event>.Continuation?
private var receiveTask: Task<Void, Never>?
private var authenticationTimeoutTask: Task<Void, Never>?
private var pendingRequests: [Int64: PendingRequest] = [:]
private var nextMessageID: Int64 = 1
fileprivate init(websocketURL: URL)
{
2025-06-19 19:25:53 -07:00
self.websocketURL = websocketURL
let configuration = URLSessionConfiguration.ephemeral
configuration.waitsForConnectivity = true
configuration.timeoutIntervalForRequest = 30
self.session = URLSession(configuration: configuration)
}
fileprivate func open(generation: Int) -> AsyncStream<Event>
{
guard generation >= latestGeneration else {
return AsyncStream { $0.finish() }
}
latestGeneration = generation
closeCurrentConnection(error: HomeAssistantError.disconnected)
let newConnectionID = UUID()
var streamContinuation: AsyncStream<Event>.Continuation?
let stream = AsyncStream<Event>(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)
2025-06-19 19:25:53 -07:00
}
fileprivate func invalidate(with error: Error)
{
guard let connectionID else { return }
failConnection(error, connectionID: connectionID)
2025-06-19 19:25:53 -07:00
}
fileprivate func markAuthenticated()
{
authenticationTimeoutTask?.cancel()
authenticationTimeoutTask = nil
2025-06-19 19:25:53 -07:00
}
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
}
}
2025-06-19 19:25:53 -07:00
@discardableResult
fileprivate func sendCommand<M: CommandMessage>(_ 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<M: CommandMessage>(_ 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
2025-06-19 19:25:53 -07:00
do {
try await Task.sleep(
nanoseconds: HomeAssistantServer.nanoseconds(
Self.requestTimeout
)
)
2025-06-19 19:25:53 -07:00
} catch {
return
2025-06-19 19:25:53 -07:00
}
await self?.finishRequest(
id: messageID,
connectionID: connectionID,
result: .failure(HomeAssistantError.requestTimedOut)
)
2025-06-19 19:25:53 -07:00
}
pendingRequests[messageID]?.timeoutTask = timeoutTask
2025-06-19 19:25:53 -07:00
Task { [weak self] in
do {
try await websocketTask.send(.string(json))
} catch {
await self?.sendFailed(
error,
requestID: messageID,
connectionID: connectionID
)
}
}
2025-06-19 19:25:53 -07:00
}
} onCancel: {
Task { [weak self] in
await self?.finishRequest(
id: messageID,
connectionID: connectionID,
result: .failure(CancellationError())
)
2025-06-19 19:25:53 -07:00
}
}
}
private func receiveMessages(
from websocketTask: URLSessionWebSocketTask,
connectionID: UUID
) async {
while !Task.isCancelled {
2025-06-19 19:25:53 -07:00
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
2025-06-19 19:25:53 -07:00
}
} catch is CancellationError {
return
2025-06-19 19:25:53 -07:00
} catch {
failConnection(error, connectionID: connectionID)
return
2025-06-19 19:25:53 -07:00
}
}
}
private func handleIncoming(_ data: Data, connectionID: UUID)
{
2025-06-19 20:43:05 -07:00
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))
2025-06-19 20:43:05 -07:00
}
default:
eventContinuation?.yield(.unhandled(envelope.type))
2025-06-19 19:25:53 -07:00
}
2025-06-19 20:43:05 -07:00
} catch {
// Future Home Assistant messages should not tear down an otherwise healthy socket.
print("[HomeAssistant] Could not decode websocket message: \(error)")
2025-06-19 19:25:53 -07:00
}
}
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<Data, Error>
) {
guard
let pendingRequest = pendingRequests[id],
pendingRequest.connectionID == connectionID
else {
return
2025-06-19 19:25:53 -07:00
}
pendingRequests.removeValue(forKey: id)
pendingRequest.timeoutTask?.cancel()
pendingRequest.continuation.resume(with: result)
2025-06-19 19:25:53 -07:00
}
private func failConnection(_ error: Error, connectionID: UUID)
2025-06-19 20:43:05 -07:00
{
guard self.connectionID == connectionID else { return }
eventContinuation?.yield(.disconnected(error))
closeCurrentConnection(error: error)
2025-06-19 20:43:05 -07:00
}
private func closeCurrentConnection(error: Error)
2025-06-19 20:43:05 -07:00
{
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)
2025-06-19 20:43:05 -07:00
}
}
}
}
// 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."
2025-06-19 19:25:53 -07:00
}
}
}
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<Result: Decodable>: 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
2025-06-19 20:43:05 -07:00
{
let entityID: String
let state: Value
let attributes: Attributes
enum Value: Decodable
{
2025-06-19 20:43:05 -07:00
case on
case off
case other(String)
init(from decoder: Decoder) throws
{
2025-06-19 20:43:05 -07:00
let container = try decoder.singleValueContainer()
let value = try container.decode(String.self)
switch value.lowercased() {
2025-06-19 20:43:05 -07:00
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
2025-06-19 20:43:05 -07:00
}
}
}
struct Attributes: Decodable
{
2025-06-19 20:43:05 -07:00
let friendlyName: String?
enum CodingKeys: String, CodingKey
{
2025-06-19 20:43:05 -07:00
case friendlyName = "friendly_name"
}
}
enum CodingKeys: String, CodingKey
{
case entityID = "entity_id"
2025-06-19 20:43:05 -07:00
case state
case attributes
2025-06-19 20:43:05 -07:00
}
}
private extension HomeAssistantDevice
2025-06-19 20:43:05 -07:00
{
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
)
}
2025-06-19 20:43:05 -07:00
}
// MARK: - Outgoing messages
private protocol CommandMessage: Encodable
{
var id: Int64 { get set }
var type: String { get }
2025-06-19 19:25:53 -07:00
}
private struct AuthMessage: Encodable
2025-06-19 19:25:53 -07:00
{
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
2025-06-19 20:43:05 -07:00
let name: String?
let labels: [String]
enum CodingKeys: String, CodingKey
{
2025-06-19 20:43:05 -07:00
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) ?? []
2025-06-19 20:43:05 -07:00
}
}
}
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"
2025-06-19 20:43:05 -07:00
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`
2025-06-19 19:25:53 -07:00
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"
}
}
}