adds lights UI
This commit is contained in:
@@ -12,10 +12,12 @@ class MainViewController: UIViewController, SwitchesViewControllerDelegate, Serv
|
|||||||
{
|
{
|
||||||
fileprivate var _serverMultiplex: ServerMultiplex = ServerMultiplex()
|
fileprivate var _serverMultiplex: ServerMultiplex = ServerMultiplex()
|
||||||
fileprivate var _visualizationController: VisualizationViewController = VisualizationViewController()
|
fileprivate var _visualizationController: VisualizationViewController = VisualizationViewController()
|
||||||
fileprivate var _switchesController: SwitchesViewController = SwitchesViewController()
|
|
||||||
fileprivate var _headerView: HeaderView = HeaderView()
|
fileprivate var _headerView: HeaderView = HeaderView()
|
||||||
fileprivate var _updateDevices: Bool = false
|
fileprivate var _updateDevices: Bool = false
|
||||||
|
|
||||||
|
fileprivate var _switchesController: SwitchesViewController = SwitchesViewController()
|
||||||
|
fileprivate var _lightsController: SwitchesViewController = SwitchesViewController()
|
||||||
|
|
||||||
override init(nibName nibNameOrNil: String?, bundle nibBundleOrNil: Bundle?)
|
override init(nibName nibNameOrNil: String?, bundle nibBundleOrNil: Bundle?)
|
||||||
{
|
{
|
||||||
super.init(nibName: nibNameOrNil, bundle: nibBundleOrNil)
|
super.init(nibName: nibNameOrNil, bundle: nibBundleOrNil)
|
||||||
@@ -46,6 +48,13 @@ class MainViewController: UIViewController, SwitchesViewControllerDelegate, Serv
|
|||||||
self.addChild(_switchesController)
|
self.addChild(_switchesController)
|
||||||
self.view.addSubview(_switchesController.view)
|
self.view.addSubview(_switchesController.view)
|
||||||
|
|
||||||
|
_lightsController.delegate = self
|
||||||
|
_lightsController.cellHeightScale = 0.7
|
||||||
|
_lightsController.actionCellLayout = .horizontal
|
||||||
|
_lightsController.labelText = "lights"
|
||||||
|
self.addChild(_lightsController)
|
||||||
|
self.view.addSubview(_lightsController.view)
|
||||||
|
|
||||||
self.view.addSubview(_headerView)
|
self.view.addSubview(_headerView)
|
||||||
|
|
||||||
_updateConnectivityStatus(.disconnected)
|
_updateConnectivityStatus(.disconnected)
|
||||||
@@ -75,10 +84,18 @@ class MainViewController: UIViewController, SwitchesViewControllerDelegate, Serv
|
|||||||
x: bodyBounds.origin.x,
|
x: bodyBounds.origin.x,
|
||||||
y: bodyBounds.origin.y,
|
y: bodyBounds.origin.y,
|
||||||
width: rint(0.5 * bodyBounds.size.width),
|
width: rint(0.5 * bodyBounds.size.width),
|
||||||
height: bodyBounds.size.height
|
height: bodyBounds.size.height / 1.5
|
||||||
)
|
)
|
||||||
_visualizationController.view.frame = visualizationFrame
|
_visualizationController.view.frame = visualizationFrame
|
||||||
|
|
||||||
|
let lightsControllerFrame = CGRect(
|
||||||
|
x: bodyBounds.origin.x,
|
||||||
|
y: visualizationFrame.maxY,
|
||||||
|
width: visualizationFrame.width,
|
||||||
|
height: bodyBounds.height - visualizationFrame.height
|
||||||
|
)
|
||||||
|
_lightsController.view.frame = lightsControllerFrame.insetBy(dx: 18.0, dy: 0.0)
|
||||||
|
|
||||||
var switchesOriginX: CGFloat = 0.0
|
var switchesOriginX: CGFloat = 0.0
|
||||||
var switchesWidth: CGFloat = 0.0
|
var switchesWidth: CGFloat = 0.0
|
||||||
if (_visualizationShouldBeVisible()) {
|
if (_visualizationShouldBeVisible()) {
|
||||||
@@ -205,13 +222,16 @@ class MainViewController: UIViewController, SwitchesViewControllerDelegate, Serv
|
|||||||
|
|
||||||
func serverMultiplex(_ multiplex: ServerMultiplex, didAddDevices devices: [AnyDevice])
|
func serverMultiplex(_ multiplex: ServerMultiplex, didAddDevices devices: [AnyDevice])
|
||||||
{
|
{
|
||||||
_switchesController.devices = Array(multiplex.devices)
|
_switchesController.devices = multiplex.switchDevices
|
||||||
|
_lightsController.devices = multiplex.lightDevices
|
||||||
|
|
||||||
_updateVisualization(false)
|
_updateVisualization(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
func serverMultiplex(_ multiplex: ServerMultiplex, devicesStateChanged devices: [AnyDevice])
|
func serverMultiplex(_ multiplex: ServerMultiplex, devicesStateChanged devices: [AnyDevice])
|
||||||
{
|
{
|
||||||
_switchesController.devicesStateChanged(devices)
|
_switchesController.devicesStateChanged(devices.switches)
|
||||||
|
_lightsController.devicesStateChanged(devices.lights)
|
||||||
}
|
}
|
||||||
|
|
||||||
func serverMultiplex(_ multiplex: ServerMultiplex, didReceiveAcknowledgementFromServer server: Server)
|
func serverMultiplex(_ multiplex: ServerMultiplex, didReceiveAcknowledgementFromServer server: Server)
|
||||||
@@ -229,3 +249,15 @@ class MainViewController: UIViewController, SwitchesViewControllerDelegate, Serv
|
|||||||
print(error.localizedDescription, to: &stderr)
|
print(error.localizedDescription, to: &stderr)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension Array where Element == AnyDevice
|
||||||
|
{
|
||||||
|
var lights: [AnyDevice] { filter { $0.type == .light } }
|
||||||
|
var switches: [AnyDevice] { filter { $0.type == .switch } }
|
||||||
|
}
|
||||||
|
|
||||||
|
extension ServerMultiplex
|
||||||
|
{
|
||||||
|
var lightDevices: [AnyDevice] { devices.filter { $0.type == .light } }
|
||||||
|
var switchDevices: [AnyDevice] { devices.filter { $0.type == .switch } }
|
||||||
|
}
|
||||||
|
|||||||
@@ -20,6 +20,12 @@ class SwitchesViewController: UIViewController,
|
|||||||
{
|
{
|
||||||
weak var delegate: SwitchesViewControllerDelegate?
|
weak var delegate: SwitchesViewControllerDelegate?
|
||||||
|
|
||||||
|
public var cellHeightScale: Double = 1.0
|
||||||
|
public var actionCellLayout: ActionCellLayout = .vertical
|
||||||
|
public var labelText: String = "switches" {
|
||||||
|
didSet { _label.text = labelText }
|
||||||
|
}
|
||||||
|
|
||||||
fileprivate var _collectionView: UICollectionView = UICollectionView(frame: CGRect.zero,
|
fileprivate var _collectionView: UICollectionView = UICollectionView(frame: CGRect.zero,
|
||||||
collectionViewLayout: UICollectionViewFlowLayout())
|
collectionViewLayout: UICollectionViewFlowLayout())
|
||||||
|
|
||||||
@@ -27,6 +33,8 @@ class SwitchesViewController: UIViewController,
|
|||||||
|
|
||||||
fileprivate var _currentDevicesHash: Int = 0
|
fileprivate var _currentDevicesHash: Int = 0
|
||||||
|
|
||||||
|
fileprivate let _label = UILabel(frame: .zero)
|
||||||
|
|
||||||
static fileprivate let collectionViewDeviceSwitchCellReuseIdentifier = "DeviceSwitchReuseID"
|
static fileprivate let collectionViewDeviceSwitchCellReuseIdentifier = "DeviceSwitchReuseID"
|
||||||
static fileprivate let collectionViewActionCellReuseIdentifier = "ActionCellReuseID"
|
static fileprivate let collectionViewActionCellReuseIdentifier = "ActionCellReuseID"
|
||||||
static fileprivate let collectionViewCellsSpacing: CGFloat = 5.0
|
static fileprivate let collectionViewCellsSpacing: CGFloat = 5.0
|
||||||
@@ -52,6 +60,12 @@ class SwitchesViewController: UIViewController,
|
|||||||
static let count: Int = { return ActionCell.allCases.count }()
|
static let count: Int = { return ActionCell.allCases.count }()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public enum ActionCellLayout
|
||||||
|
{
|
||||||
|
case horizontal
|
||||||
|
case vertical
|
||||||
|
}
|
||||||
|
|
||||||
override init(nibName nibNameOrNil: String?, bundle nibBundleOrNil: Bundle?)
|
override init(nibName nibNameOrNil: String?, bundle nibBundleOrNil: Bundle?)
|
||||||
{
|
{
|
||||||
super.init(nibName: nibNameOrNil, bundle: nibBundleOrNil)
|
super.init(nibName: nibNameOrNil, bundle: nibBundleOrNil)
|
||||||
@@ -84,6 +98,11 @@ class SwitchesViewController: UIViewController,
|
|||||||
{
|
{
|
||||||
super.viewDidLoad()
|
super.viewDidLoad()
|
||||||
|
|
||||||
|
_label.text = labelText
|
||||||
|
_label.font = UIFont(name: "Orbitron-Medium", size: 14.0)
|
||||||
|
_label.textColor = .white.withAlphaComponent(0.8)
|
||||||
|
self.view.addSubview(_label)
|
||||||
|
|
||||||
let deviceCellReuseID = SwitchesViewController.collectionViewDeviceSwitchCellReuseIdentifier
|
let deviceCellReuseID = SwitchesViewController.collectionViewDeviceSwitchCellReuseIdentifier
|
||||||
let actionCellReuseID = SwitchesViewController.collectionViewActionCellReuseIdentifier
|
let actionCellReuseID = SwitchesViewController.collectionViewActionCellReuseIdentifier
|
||||||
let layout = _collectionView.collectionViewLayout as! UICollectionViewFlowLayout
|
let layout = _collectionView.collectionViewLayout as! UICollectionViewFlowLayout
|
||||||
@@ -106,7 +125,23 @@ class SwitchesViewController: UIViewController,
|
|||||||
super.viewDidLayoutSubviews()
|
super.viewDidLayoutSubviews()
|
||||||
|
|
||||||
let bounds = self.view.bounds
|
let bounds = self.view.bounds
|
||||||
_collectionView.frame = bounds
|
|
||||||
|
let labelSpacing = 8.0
|
||||||
|
let labelSize = _label.sizeThatFits(bounds.size)
|
||||||
|
_label.frame = CGRect(
|
||||||
|
x: 0.0,
|
||||||
|
y: 0.0,
|
||||||
|
width: labelSize.width,
|
||||||
|
height: labelSize.height
|
||||||
|
)
|
||||||
|
|
||||||
|
_collectionView.frame = CGRect(
|
||||||
|
x: 0.0,
|
||||||
|
y: _label.frame.maxY + labelSpacing,
|
||||||
|
width: bounds.width,
|
||||||
|
height: bounds.height - _label.frame.height - labelSpacing
|
||||||
|
)
|
||||||
|
|
||||||
_collectionView.collectionViewLayout.invalidateLayout()
|
_collectionView.collectionViewLayout.invalidateLayout()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,9 +231,16 @@ class SwitchesViewController: UIViewController,
|
|||||||
|
|
||||||
let dimensions = floor((collectionView.bounds.size.width / cellsPerRow) - ((spacing * (cellsPerRow - 1.0)) / cellsPerRow))
|
let dimensions = floor((collectionView.bounds.size.width / cellsPerRow) - ((spacing * (cellsPerRow - 1.0)) / cellsPerRow))
|
||||||
if (indexPath.section == SwitchesViewController.actionCellsSectionIdentifier) {
|
if (indexPath.section == SwitchesViewController.actionCellsSectionIdentifier) {
|
||||||
return CGSize(width: collectionView.bounds.size.width, height: rint(dimensions / 1.5))
|
let bounds = collectionView.bounds
|
||||||
|
let height = rint(dimensions / 1.5) * cellHeightScale
|
||||||
|
switch actionCellLayout {
|
||||||
|
case .horizontal:
|
||||||
|
return CGSize(width: (bounds.width - spacing) / 2.0, height: height)
|
||||||
|
case .vertical:
|
||||||
|
return CGSize(width: bounds.size.width, height: height)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
return CGSize(width: dimensions, height: dimensions)
|
return CGSize(width: dimensions, height: dimensions * cellHeightScale)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ enum DeviceState : String, Codable
|
|||||||
enum DeviceType : String, Codable
|
enum DeviceType : String, Codable
|
||||||
{
|
{
|
||||||
case `switch`
|
case `switch`
|
||||||
|
case light
|
||||||
}
|
}
|
||||||
|
|
||||||
protocol Device
|
protocol Device
|
||||||
|
|||||||
@@ -6,14 +6,13 @@
|
|||||||
// Copyright © 2025 XION. All rights reserved.
|
// Copyright © 2025 XION. All rights reserved.
|
||||||
//
|
//
|
||||||
|
|
||||||
|
import Combine
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
class HomeAssistantServer: Server
|
class HomeAssistantServer: Server
|
||||||
{
|
{
|
||||||
var connectionStatus: ConnectionStatus = .disconnected
|
var connectionStatus: ConnectionStatus = .disconnected
|
||||||
|
|
||||||
private typealias Reply = (Data) -> Void
|
|
||||||
|
|
||||||
// Configure:
|
// Configure:
|
||||||
private let authToken: String = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiI2ZTAwODI1N2Y4N2Q0OWIxYWRkN2ExNTBhOWRmNGZiZCIsImlhdCI6MTc1MDMwMDg5NiwiZXhwIjoyMDY1NjYwODk2fQ.KRf9GpRZdpW9Z_vkbL3sl74rgKS7eAyc8kO0a5jLTgg"
|
private let authToken: String = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiI2ZTAwODI1N2Y4N2Q0OWIxYWRkN2ExNTBhOWRmNGZiZCIsImlhdCI6MTc1MDMwMDg5NiwiZXhwIjoyMDY1NjYwODk2fQ.KRf9GpRZdpW9Z_vkbL3sl74rgKS7eAyc8kO0a5jLTgg"
|
||||||
|
|
||||||
@@ -21,59 +20,60 @@ class HomeAssistantServer: Server
|
|||||||
private static let filterLabel = "control_panel"
|
private static let filterLabel = "control_panel"
|
||||||
|
|
||||||
private let url: URL
|
private let url: URL
|
||||||
private var websocketTask: URLSessionWebSocketTask? = nil
|
private let liaison: Liaison
|
||||||
private var pendingMessages: [MessageID: Reply] = [:]
|
|
||||||
private var connectionContinuations: [CheckedContinuation<Bool, Never>] = []
|
private var connectionContinuations: [CheckedContinuation<Bool, Never>] = []
|
||||||
|
private var eventTask: Task<(), any Error>? = nil
|
||||||
|
|
||||||
|
typealias NoResult = [String: String]
|
||||||
|
|
||||||
required init(_ url: URL) {
|
required init(_ url: URL) {
|
||||||
self.url = url
|
self.url = url
|
||||||
|
|
||||||
|
let websocketURL = url.appending(component: "websocket").websocket()
|
||||||
|
self.liaison = Liaison(websocketURL: websocketURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
func connect(_ completion: @escaping ((any Error)?) -> Void) {
|
func connect(_ completion: @escaping ((any Error)?) -> Void) {
|
||||||
guard websocketTask == nil else { return }
|
guard self.eventTask == nil else {
|
||||||
|
completion(nil)
|
||||||
let url = url.appending(component: "websocket").websocket()
|
return
|
||||||
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 {
|
|
||||||
encounteredWebsocketError(error)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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)
|
completion(nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func disconnect(_ completion: ((any Error)?) -> Void) {
|
func disconnect(_ completion: ((any Error)?) -> Void) {
|
||||||
websocketTask?.cancel()
|
Task { await liaison.disconnect() }
|
||||||
websocketTask = nil
|
|
||||||
completion(nil)
|
completion(nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func fetchDevices(_ completion: @escaping (Result<[AnyDevice], any Error>) -> Void) {
|
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 {
|
Task {
|
||||||
await waitConnected()
|
await waitConnected()
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let registryEntries: [RegistryEntry] = try await sendMessage(RegistryEntriesMessage())
|
let registryEntries: [RegistryEntry] = try await liaison.sendMessage(RegistryEntriesMessage())
|
||||||
let filteredEntries = registryEntries.filter { $0.labels.contains(Self.filterLabel) }
|
let filteredEntries = registryEntries.filter { $0.labels.contains(Self.filterLabel) }
|
||||||
|
|
||||||
let states: [EntityState] = try await sendMessage(GetStatesMessage())
|
let states: [EntityState] = try await liaison.sendMessage(GetStatesMessage())
|
||||||
let stateMap = states.reduce(into: [String: DeviceState]()) { partialResult, state in
|
let stateMap = states.reduce(into: [String: DeviceState]()) { partialResult, state in
|
||||||
partialResult[state.entityId] = switch state.state {
|
partialResult[state.entityId] = switch state.state {
|
||||||
case .on: .on
|
case .on: .on
|
||||||
@@ -95,6 +95,11 @@ class HomeAssistantServer: Server
|
|||||||
}
|
}
|
||||||
|
|
||||||
func toggleDevice(_ device: AnyDevice, state: DeviceState, completion: @escaping ((any Error)?) -> Void) {
|
func toggleDevice(_ device: AnyDevice, state: DeviceState, completion: @escaping ((any Error)?) -> Void) {
|
||||||
|
guard connectionStatus != .error else {
|
||||||
|
completion(ServerError.disconnected)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
Task {
|
Task {
|
||||||
await waitConnected()
|
await waitConnected()
|
||||||
|
|
||||||
@@ -105,7 +110,19 @@ class HomeAssistantServer: Server
|
|||||||
case .on: .turnOn
|
case .on: .turnOn
|
||||||
}
|
}
|
||||||
|
|
||||||
let _: Bool = try await sendMessage(CallServiceMessage(entityID: entityID, service: service))
|
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
|
||||||
|
)
|
||||||
|
|
||||||
|
let _: NoResult = try await liaison.sendMessage(message)
|
||||||
Task { @MainActor in completion(nil) }
|
Task { @MainActor in completion(nil) }
|
||||||
} catch {
|
} catch {
|
||||||
Task { @MainActor in completion(error) }
|
Task { @MainActor in completion(error) }
|
||||||
@@ -125,77 +142,40 @@ class HomeAssistantServer: Server
|
|||||||
return await withCheckedContinuation { continuation in
|
return await withCheckedContinuation { continuation in
|
||||||
self.connectionContinuations.append(continuation)
|
self.connectionContinuations.append(continuation)
|
||||||
}
|
}
|
||||||
|
} else if connectionStatus == .error {
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@discardableResult
|
|
||||||
private func sendMessage<R: Decodable>(_ 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<R>.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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Task {
|
|
||||||
do {
|
|
||||||
let encodedMessage = try JSONEncoder().encode(message)
|
|
||||||
let jsonString = String(data: encodedMessage, encoding: .utf8)!
|
|
||||||
try await websocketTask?.send(.string(jsonString))
|
|
||||||
} catch {
|
|
||||||
print("Error sending message: \(error)")
|
|
||||||
encounteredWebsocketError(error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func encounteredWebsocketError(_ error: any Error) {
|
private func encounteredWebsocketError(_ error: any Error) {
|
||||||
print("Websocket Error: \(error)")
|
print("Websocket Error: \(error)")
|
||||||
self.connectionStatus = .error
|
self.connectionStatus = .error
|
||||||
self.websocketTask?.cancel()
|
self.eventTask?.cancel()
|
||||||
self.websocketTask = nil
|
self.eventTask = nil
|
||||||
// reconnects the next time we're refreshed.
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func decodeEvent(_ data: Data) async {
|
private func handleLiaisonEvent(_ event: Liaison.Event) async throws {
|
||||||
do {
|
switch event {
|
||||||
let decoder = JSONDecoder()
|
case .partialEvent(let partialEvent, _):
|
||||||
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 {
|
switch partialEvent.type {
|
||||||
case .authRequired:
|
case .authRequired:
|
||||||
await authenticate()
|
Task { await authenticate() }
|
||||||
case .authOK:
|
case .authOK:
|
||||||
didConnectToWebsocket()
|
Task { didConnectToWebsocket() }
|
||||||
case .result:
|
default:
|
||||||
guard let id = partialEvent.id else { return }
|
break
|
||||||
Task { @MainActor in
|
|
||||||
self.pendingMessages[id]?(data)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case .pong:
|
||||||
|
didConnectToWebsocket()
|
||||||
|
|
||||||
|
case .disconnected(let error):
|
||||||
|
encounteredWebsocketError(error)
|
||||||
|
|
||||||
|
case .error(let error):
|
||||||
|
encounteredWebsocketError(error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -210,12 +190,188 @@ class HomeAssistantServer: Server
|
|||||||
|
|
||||||
private func authenticate() async {
|
private func authenticate() async {
|
||||||
do {
|
do {
|
||||||
let _: Bool = try await sendMessage(AuthMessage(token: authToken))
|
let _: NoResult = try await liaison.sendMessage(AuthMessage(token: authToken))
|
||||||
} catch {
|
} catch {
|
||||||
print("Error authenticating: \(error)")
|
print("Error authenticating: \(error)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
fileprivate func sendMessage<R: Decodable>(_ message: Message) async throws -> R {
|
||||||
|
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<R>.self, from: response)
|
||||||
|
if let result = decodedResponse.result {
|
||||||
|
continuation.resume(returning: result)
|
||||||
|
} else if R.self != NoResult.self {
|
||||||
|
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
|
||||||
|
let decoder = JSONDecoder()
|
||||||
|
|
||||||
|
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)!
|
||||||
|
let partialEvent = try decoder.decode(PartialEvent.self, from: data)
|
||||||
|
await handlePartialEvent(partialEvent, data: data)
|
||||||
|
case .data(let data):
|
||||||
|
let partialEvent = try decoder.decode(PartialEvent.self, from: data)
|
||||||
|
await handlePartialEvent(partialEvent, 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(_ event: PartialEvent, data: 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
// MARK: - Types
|
||||||
|
|
||||||
struct PartialEvent: Codable
|
struct PartialEvent: Codable
|
||||||
@@ -239,12 +395,17 @@ class HomeAssistantServer: Server
|
|||||||
case result = "result"
|
case result = "result"
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ErrorResult: Codable, Error
|
struct ErrorResult: Codable, Swift.Error
|
||||||
{
|
{
|
||||||
let code: String
|
let code: String
|
||||||
let message: String
|
let message: String
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum ServerError: Swift.Error
|
||||||
|
{
|
||||||
|
case disconnected
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Result Types
|
// MARK: - Result Types
|
||||||
|
|
||||||
struct RegistryEntry: Codable
|
struct RegistryEntry: Codable
|
||||||
@@ -305,10 +466,15 @@ class HomeAssistantServer: Server
|
|||||||
extension HomeAssistantDevice
|
extension HomeAssistantDevice
|
||||||
{
|
{
|
||||||
convenience init(entity: HomeAssistantServer.RegistryEntry, state: DeviceState) {
|
convenience init(entity: HomeAssistantServer.RegistryEntry, state: DeviceState) {
|
||||||
|
var type: DeviceType = .switch
|
||||||
|
if entity.labels.contains("control_panel_light") {
|
||||||
|
type = .light
|
||||||
|
}
|
||||||
|
|
||||||
self.init(
|
self.init(
|
||||||
name: entity.name?.replacingOccurrences(of: " Switch", with: "") ?? entity.entityId,
|
name: entity.name?.replacingOccurrences(of: " Switch", with: "") ?? entity.entityId,
|
||||||
serial: entity.entityId,
|
serial: entity.entityId,
|
||||||
type: .switch,
|
type: type,
|
||||||
state: state
|
state: state
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -329,28 +495,38 @@ extension MessageID
|
|||||||
|
|
||||||
fileprivate protocol Message: Encodable
|
fileprivate protocol Message: Encodable
|
||||||
{
|
{
|
||||||
var id: MessageID { get }
|
var id: MessageID { get set }
|
||||||
var type: String { get }
|
var type: String { get }
|
||||||
|
|
||||||
|
func nextMessageID() -> MessageID
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Message
|
||||||
|
{
|
||||||
|
func nextMessageID() -> MessageID { .next() }
|
||||||
}
|
}
|
||||||
|
|
||||||
fileprivate struct RegistryEntriesMessage: Message
|
fileprivate struct RegistryEntriesMessage: Message
|
||||||
{
|
{
|
||||||
let id: MessageID = .next()
|
var id: MessageID = nil
|
||||||
let type: String = "config/entity_registry/list"
|
let type: String = "config/entity_registry/list"
|
||||||
}
|
}
|
||||||
|
|
||||||
fileprivate struct GetStatesMessage: Message
|
fileprivate struct GetStatesMessage: Message
|
||||||
{
|
{
|
||||||
let id: MessageID = .next()
|
var id: MessageID = nil
|
||||||
let type: String = "get_states"
|
let type: String = "get_states"
|
||||||
}
|
}
|
||||||
|
|
||||||
fileprivate struct AuthMessage: Message
|
fileprivate struct AuthMessage: Message
|
||||||
{
|
{
|
||||||
let id: MessageID = nil
|
var id: MessageID = nil
|
||||||
let type: String = "auth"
|
let type: String = "auth"
|
||||||
let token: String
|
let token: String
|
||||||
|
|
||||||
|
// No ID for auth messages
|
||||||
|
func nextMessageID() -> MessageID { nil }
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case id
|
case id
|
||||||
case type
|
case type
|
||||||
@@ -360,7 +536,7 @@ fileprivate struct AuthMessage: Message
|
|||||||
|
|
||||||
fileprivate struct CallServiceMessage: Message
|
fileprivate struct CallServiceMessage: Message
|
||||||
{
|
{
|
||||||
let id: MessageID = .next()
|
var id: MessageID = nil
|
||||||
let type: String = "call_service"
|
let type: String = "call_service"
|
||||||
|
|
||||||
let service: Service
|
let service: Service
|
||||||
@@ -376,6 +552,7 @@ fileprivate struct CallServiceMessage: Message
|
|||||||
enum ServiceDomain: String, Codable
|
enum ServiceDomain: String, Codable
|
||||||
{
|
{
|
||||||
case `switch`
|
case `switch`
|
||||||
|
case light
|
||||||
}
|
}
|
||||||
|
|
||||||
enum Service: String, Codable
|
enum Service: String, Codable
|
||||||
|
|||||||
Reference in New Issue
Block a user