From f3341ee06467b1edfe85d8b4a5b8eea48fdcbc6a Mon Sep 17 00:00:00 2001 From: James Magahern Date: Mon, 27 Jul 2026 16:23:40 -0700 Subject: [PATCH] Improve Home Assistant connection reliability --- README.md | 46 +- XIONControlPanel.xcodeproj/project.pbxproj | 138 ++ .../xcschemes/XIONControlPanel.xcscheme | 138 ++ .../Application/AppDelegate.swift | 11 +- .../Controllers/MainViewController.swift | 165 +- .../Controllers/SwitchesViewController.swift | 77 +- .../VisualizationViewController.swift | 3 +- XIONControlPanel/Models/DeviceProtocol.swift | 4 +- XIONControlPanel/Models/HubitatDevice.swift | 5 +- .../Servers/HomeAssistantServer.swift | 1498 +++++++++++------ .../Servers/ServerMultiplex.swift | 83 +- XIONControlPanel/Servers/ServerProtocol.swift | 4 +- XIONControlPanel/Servers/WemoServer.swift | 30 +- XIONControlPanel/SupportingFiles/Info.plist | 2 + XIONControlPanel/Views/WemoCellView.swift | 5 + .../HomeAssistantIntegrationTests.swift | 184 ++ 16 files changed, 1696 insertions(+), 697 deletions(-) create mode 100644 XIONControlPanel.xcodeproj/xcshareddata/xcschemes/XIONControlPanel.xcscheme create mode 100644 XIONControlPanelTests/HomeAssistantIntegrationTests.swift diff --git a/README.md b/README.md index d3a3a5a..825cdc2 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,42 @@ -XION Arcade WEMO-Enabled Control Panel +XION Arcade Control Panel ================================================================================ -This is an iOS application designed for remote control of XION Arcade devices. -The app is designed for XION but could work with any WEMO-enabled household by -changing only one line of code. +This is an iOS control panel for the XION/Xaibatsu arcade. It connects to Home +Assistant's websocket API, displays entities with the `control_panel` label, and +renders entities with the additional `control_panel_light` label in the lights +section. -The control panel is powered by the server-side client application called -[Ouimeaux](http://ouimeaux.readthedocs.org). The application talks to any -Ouimeaux server instance that is running and uses this to retrieve device -information and to toggle devices on the network. +Configuration +-------------------------------------------------------------------------------- +The Home Assistant access token is intentionally not stored in source control. +Provide it either through the `HOME_ASSISTANT_ACCESS_TOKEN` process environment +variable or as the `HOME_ASSISTANT_ACCESS_TOKEN` Xcode build setting. The latter +is expanded into the app's `HomeAssistantAccessToken` Info.plist entry. + +For a Simulator launch from the command line: + +```sh +SIMCTL_CHILD_HOME_ASSISTANT_ACCESS_TOKEN='token' \ + xcrun simctl launch booted com.zanneth.XIONControlPanel +``` + +The client disconnects cleanly when the app resigns active, reconnects and +refreshes immediately when it returns, and uses Home Assistant `ping`/`pong` +messages as a heartbeat while active. + +Testing +-------------------------------------------------------------------------------- + +The live integration test is opt-in because it briefly changes the `Asteroids` +switch and restores its original state: + +```sh +HOME_ASSISTANT_ACCESS_TOKEN='token' \ +RUN_LIVE_HOME_ASSISTANT_TESTS=1 \ +xcodebuild -project XIONControlPanel.xcodeproj \ + -scheme XIONControlPanel \ + -destination 'platform=iOS Simulator,name=iPhone 17' \ + -only-testing:XIONControlPanelTests/HomeAssistantIntegrationTests \ + test +``` diff --git a/XIONControlPanel.xcodeproj/project.pbxproj b/XIONControlPanel.xcodeproj/project.pbxproj index 1c13b08..443ae86 100644 --- a/XIONControlPanel.xcodeproj/project.pbxproj +++ b/XIONControlPanel.xcodeproj/project.pbxproj @@ -43,8 +43,20 @@ 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 */; }; + AA0000030000000000000000 /* HomeAssistantIntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000010000000000000000 /* HomeAssistantIntegrationTests.swift */; }; + AA0000050000000000000000 /* XCTest.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = AA0000040000000000000000 /* XCTest.framework */; }; /* End PBXBuildFile section */ +/* Begin PBXContainerItemProxy section */ + AA00000B0000000000000000 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 0C3CAE9C1C3350C800B856AD /* Project object */; + proxyType = 1; + remoteGlobalIDString = 0C3CAEA31C3350C800B856AD; + remoteInfo = XIONControlPanel; + }; +/* End PBXContainerItemProxy section */ + /* Begin PBXFileReference section */ 0C20E2B51C33514100BA789B /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 0C20E2BA1C33529700BA789B /* MainViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MainViewController.swift; sourceTree = ""; }; @@ -87,6 +99,9 @@ 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 = ""; }; + AA0000010000000000000000 /* HomeAssistantIntegrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeAssistantIntegrationTests.swift; sourceTree = ""; }; + AA0000020000000000000000 /* XIONControlPanelTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = XIONControlPanelTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + AA0000040000000000000000 /* XCTest.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = XCTest.framework; path = System/Library/Frameworks/XCTest.framework; sourceTree = SDKROOT; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -97,6 +112,14 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + AA0000080000000000000000 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + AA0000050000000000000000 /* XCTest.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -166,6 +189,7 @@ isa = PBXGroup; children = ( 0C3CAEA61C3350C800B856AD /* XIONControlPanel */, + AA0000060000000000000000 /* XIONControlPanelTests */, 0C3CAEA51C3350C800B856AD /* Products */, ); sourceTree = ""; @@ -174,6 +198,7 @@ isa = PBXGroup; children = ( 0C3CAEA41C3350C800B856AD /* XION.app */, + AA0000020000000000000000 /* XIONControlPanelTests.xctest */, ); name = Products; sourceTree = ""; @@ -244,6 +269,14 @@ path = Servers; sourceTree = ""; }; + AA0000060000000000000000 /* XIONControlPanelTests */ = { + isa = PBXGroup; + children = ( + AA0000010000000000000000 /* HomeAssistantIntegrationTests.swift */, + ); + path = XIONControlPanelTests; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -264,6 +297,24 @@ productReference = 0C3CAEA41C3350C800B856AD /* XION.app */; productType = "com.apple.product-type.application"; }; + AA00000A0000000000000000 /* XIONControlPanelTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = AA00000F0000000000000000 /* Build configuration list for PBXNativeTarget "XIONControlPanelTests" */; + buildPhases = ( + AA0000070000000000000000 /* Sources */, + AA0000080000000000000000 /* Frameworks */, + AA0000090000000000000000 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + AA00000C0000000000000000 /* PBXTargetDependency */, + ); + name = XIONControlPanelTests; + productName = XIONControlPanelTests; + productReference = AA0000020000000000000000 /* XIONControlPanelTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -280,6 +331,12 @@ LastSwiftMigration = 1010; ProvisioningStyle = Automatic; }; + AA00000A0000000000000000 = { + CreatedOnToolsVersion = 26.5; + DevelopmentTeam = DQQH5H6GBD; + ProvisioningStyle = Automatic; + TestTargetID = 0C3CAEA31C3350C800B856AD; + }; }; }; buildConfigurationList = 0C3CAE9F1C3350C800B856AD /* Build configuration list for PBXProject "XIONControlPanel" */; @@ -297,6 +354,7 @@ projectRoot = ""; targets = ( 0C3CAEA31C3350C800B856AD /* XIONControlPanel */, + AA00000A0000000000000000 /* XIONControlPanelTests */, ); }; /* End PBXProject section */ @@ -321,6 +379,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + AA0000090000000000000000 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -355,8 +420,24 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + AA0000070000000000000000 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + AA0000030000000000000000 /* HomeAssistantIntegrationTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ +/* Begin PBXTargetDependency section */ + AA00000C0000000000000000 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 0C3CAEA31C3350C800B856AD /* XIONControlPanel */; + targetProxy = AA00000B0000000000000000 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + /* Begin PBXVariantGroup section */ 0C3CAEB01C3350C800B856AD /* LaunchScreen.storyboard */ = { isa = PBXVariantGroup; @@ -485,6 +566,7 @@ CURRENT_PROJECT_VERSION = 3; DEVELOPMENT_TEAM = DQQH5H6GBD; INFOPLIST_FILE = "$(SRCROOT)/XIONControlPanel/SupportingFiles/Info.plist"; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; MARKETING_VERSION = 2.0; PRODUCT_BUNDLE_IDENTIFIER = com.zanneth.XIONControlPanel; @@ -492,6 +574,7 @@ PROVISIONING_PROFILE = ""; PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_OBJC_BRIDGING_HEADER = "XIONControlPanel/SupportingFiles/XIONControlPanel-Bridging-Header.h"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; }; @@ -507,6 +590,7 @@ CURRENT_PROJECT_VERSION = 3; DEVELOPMENT_TEAM = DQQH5H6GBD; INFOPLIST_FILE = "$(SRCROOT)/XIONControlPanel/SupportingFiles/Info.plist"; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; MARKETING_VERSION = 2.0; PRODUCT_BUNDLE_IDENTIFIER = com.zanneth.XIONControlPanel; @@ -519,6 +603,51 @@ }; name = Release; }; + AA00000D0000000000000000 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = DQQH5H6GBD; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.zanneth.XIONControlPanelTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/XION.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/XION"; + }; + name = Debug; + }; + AA00000E0000000000000000 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = DQQH5H6GBD; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.zanneth.XIONControlPanelTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/XION.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/XION"; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -540,6 +669,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + AA00000F0000000000000000 /* Build configuration list for PBXNativeTarget "XIONControlPanelTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + AA00000D0000000000000000 /* Debug */, + AA00000E0000000000000000 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ }; rootObject = 0C3CAE9C1C3350C800B856AD /* Project object */; diff --git a/XIONControlPanel.xcodeproj/xcshareddata/xcschemes/XIONControlPanel.xcscheme b/XIONControlPanel.xcodeproj/xcshareddata/xcschemes/XIONControlPanel.xcscheme new file mode 100644 index 0000000..44b53ef --- /dev/null +++ b/XIONControlPanel.xcodeproj/xcshareddata/xcschemes/XIONControlPanel.xcscheme @@ -0,0 +1,138 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/XIONControlPanel/Application/AppDelegate.swift b/XIONControlPanel/Application/AppDelegate.swift index ec59306..87d92e8 100644 --- a/XIONControlPanel/Application/AppDelegate.swift +++ b/XIONControlPanel/Application/AppDelegate.swift @@ -17,7 +17,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { - self.window = UIWindow(frame: UIScreen.main.bounds) + self.window = UIWindow() self.window?.rootViewController = self.mainViewController self.window?.makeKeyAndVisible() @@ -26,12 +26,17 @@ class AppDelegate: UIResponder, UIApplicationDelegate func applicationDidBecomeActive(_ application: UIApplication) { - self.mainViewController.viewDidAppear(false) + mainViewController.applicationDidBecomeActive() + } + + func applicationWillResignActive(_ application: UIApplication) + { + mainViewController.applicationWillResignActive() } func applicationDidEnterBackground(_ application: UIApplication) { - self.mainViewController.viewDidDisappear(false) + mainViewController.applicationWillResignActive() } func application(_ application: UIApplication, diff --git a/XIONControlPanel/Controllers/MainViewController.swift b/XIONControlPanel/Controllers/MainViewController.swift index 0a9533f..d9a4b7c 100644 --- a/XIONControlPanel/Controllers/MainViewController.swift +++ b/XIONControlPanel/Controllers/MainViewController.swift @@ -13,7 +13,9 @@ class MainViewController: UIViewController, SwitchesViewControllerDelegate, Serv fileprivate var _serverMultiplex: ServerMultiplex = ServerMultiplex() fileprivate var _visualizationController: VisualizationViewController = VisualizationViewController() fileprivate var _headerView: HeaderView = HeaderView() - fileprivate var _updateDevices: Bool = false + fileprivate var _refreshTimer: Timer? + fileprivate var _isApplicationActive: Bool = false + fileprivate var _isViewVisible: Bool = false fileprivate var _switchesController: SwitchesViewController = SwitchesViewController() fileprivate var _lightsController: SwitchesViewController = SwitchesViewController() @@ -58,6 +60,7 @@ class MainViewController: UIViewController, SwitchesViewControllerDelegate, Serv self.view.addSubview(_headerView) _updateConnectivityStatus(.disconnected) + _updateSizeClassPresentation() } override func viewDidLayoutSubviews() @@ -80,61 +83,68 @@ class MainViewController: UIViewController, SwitchesViewControllerDelegate, Serv _headerView.frame = headerBounds - let visualizationFrame = CGRect( - x: bodyBounds.origin.x, - y: bodyBounds.origin.y, - width: rint(0.5 * bodyBounds.size.width), - height: bodyBounds.size.height / 1.5 - ) - _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 switchesWidth: CGFloat = 0.0 - if (_visualizationShouldBeVisible()) { - switchesOriginX = visualizationFrame.maxX - switchesWidth = bodyBounds.size.width - visualizationFrame.size.width + if _visualizationShouldBeVisible() { + let visualizationFrame = CGRect( + x: bodyBounds.origin.x, + y: bodyBounds.origin.y, + width: rint(0.5 * bodyBounds.size.width), + height: bodyBounds.size.height / 1.5 + ) + _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 + ) + + _switchesController.view.frame = CGRect( + x: visualizationFrame.maxX, + y: bodyBounds.origin.y, + width: bodyBounds.width - visualizationFrame.width, + height: bodyBounds.height + ) } else { - switchesOriginX = 0.0 - switchesWidth = bodyBounds.size.width + let sectionSpacing: CGFloat = 8 + let switchesHeight = rint(bodyBounds.height * 0.68) + + _visualizationController.view.frame = .zero + _switchesController.view.frame = CGRect( + x: bodyBounds.minX, + y: bodyBounds.minY, + width: bodyBounds.width, + height: switchesHeight + ) + _lightsController.view.frame = CGRect( + x: bodyBounds.minX + sectionSpacing, + y: bodyBounds.minY + switchesHeight + sectionSpacing, + width: bodyBounds.width - (sectionSpacing * 2), + height: bodyBounds.height - switchesHeight - sectionSpacing + ) } - - let switchesControllerFrame = CGRect( - x: switchesOriginX, - y: bodyBounds.origin.y, - width: switchesWidth, - height: bodyBounds.size.height - ) - _switchesController.view.frame = switchesControllerFrame + + _updateSizeClassPresentation() } override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) - - UIApplication.shared.isIdleTimerDisabled = true - - _headerView.xionLogoView.beginAnimating() - - if _serverMultiplex.devices.count == 0 { - // Do initial refresh - _updateConnectivityStatus(.connecting) - _serverMultiplex.refreshDevices() - _startUpdatingDevices() - } + + _isViewVisible = true + _updateActiveState() } override func viewDidDisappear(_ animated: Bool) { super.viewDidDisappear(animated) - UIApplication.shared.isIdleTimerDisabled = false + + _isViewVisible = false + _updateActiveState() } override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) @@ -155,7 +165,21 @@ class MainViewController: UIViewController, SwitchesViewControllerDelegate, Serv _updateVisualization(true) for device in devices { - _serverMultiplex.toggleDeviceState(device, state: device.state, completion: { (error: Error?) -> Void in }) + _serverMultiplex.toggleDeviceState( + device, + state: device.state + ) { [weak self] error in + guard let error else { return } + + DispatchQueue.main.async { + guard let self else { return } + self._updateConnectivityStatus(.error) + self._serverMultiplex.refreshDevices() + + var stderr = StandardErrorOutputStream() + print(error.localizedDescription, to: &stderr) + } + } } } @@ -200,22 +224,41 @@ class MainViewController: UIViewController, SwitchesViewControllerDelegate, Serv } } - internal func _startUpdatingDevices() + internal func applicationDidBecomeActive() { - _updateDevices = true - - let interval = DispatchTime.now() + Double(Int64(10 * Double(NSEC_PER_SEC))) / Double(NSEC_PER_SEC) - DispatchQueue.main.asyncAfter(deadline: interval) { () -> Void in - if (self._updateDevices) { - self._serverMultiplex.refreshDevices() - self._startUpdatingDevices() - } - } + _isApplicationActive = true + _updateActiveState() } - - internal func _stopUpdatingDevices() + + internal func applicationWillResignActive() { - _updateDevices = false + _isApplicationActive = false + _updateActiveState() + } + + private func _updateActiveState() + { + let shouldBeActive = _isApplicationActive && _isViewVisible + + if shouldBeActive, _refreshTimer == nil { + UIApplication.shared.isIdleTimerDisabled = true + _headerView.xionLogoView.beginAnimating() + _updateConnectivityStatus(.connecting) + _serverMultiplex.refreshDevices() + + let timer = Timer(timeInterval: 10, repeats: true) { [weak self] _ in + self?._serverMultiplex.refreshDevices() + } + RunLoop.main.add(timer, forMode: .common) + _refreshTimer = timer + } else if !shouldBeActive, _refreshTimer != nil { + _refreshTimer?.invalidate() + _refreshTimer = nil + _serverMultiplex.disconnect() + _updateConnectivityStatus(.disconnected) + _headerView.xionLogoView.stopAnimating() + UIApplication.shared.isIdleTimerDisabled = false + } } // MARK: Server Multiplex Delegate @@ -232,6 +275,7 @@ class MainViewController: UIViewController, SwitchesViewControllerDelegate, Serv { _switchesController.devicesStateChanged(devices.switches) _lightsController.devicesStateChanged(devices.lights) + _updateVisualization(true) } func serverMultiplex(_ multiplex: ServerMultiplex, didReceiveAcknowledgementFromServer server: Server) @@ -240,6 +284,11 @@ class MainViewController: UIViewController, SwitchesViewControllerDelegate, Serv let groupConnectionStatus = multiplex.groupConnectionStatus() _updateConnectivityStatus(groupConnectionStatus) } + + func serverMultiplexConnectionStatusDidChange(_ multiplex: ServerMultiplex) + { + _updateConnectivityStatus(multiplex.groupConnectionStatus()) + } func serverMultiplex(_ multiplex: ServerMultiplex, didEncounterError error: Error) { diff --git a/XIONControlPanel/Controllers/SwitchesViewController.swift b/XIONControlPanel/Controllers/SwitchesViewController.swift index 517b493..af3d4ff 100644 --- a/XIONControlPanel/Controllers/SwitchesViewController.swift +++ b/XIONControlPanel/Controllers/SwitchesViewController.swift @@ -10,7 +10,7 @@ import Darwin import Foundation import UIKit -protocol SwitchesViewControllerDelegate: class +protocol SwitchesViewControllerDelegate: AnyObject { func switchesViewControllerDidToggleDevices(_ controller: SwitchesViewController, devices: [AnyDevice]) } @@ -29,9 +29,8 @@ class SwitchesViewController: UIViewController, fileprivate var _collectionView: UICollectionView = UICollectionView(frame: CGRect.zero, collectionViewLayout: UICollectionViewFlowLayout()) - fileprivate var _collectionViewDataSource: UICollectionViewDiffableDataSource! - - fileprivate var _currentDevicesHash: Int = 0 + fileprivate var _collectionViewDataSource: + UICollectionViewDiffableDataSource! fileprivate let _label = UILabel(frame: .zero) @@ -42,7 +41,7 @@ class SwitchesViewController: UIViewController, static fileprivate let actionCellsSectionIdentifier = 0 static fileprivate let switchCellsSectionIdentifier = 1 - fileprivate enum ActionCell: Int, CaseIterable + fileprivate enum ActionCell: Int, CaseIterable, Hashable { case allOn case allOff @@ -59,6 +58,12 @@ class SwitchesViewController: UIViewController, static let count: Int = { return ActionCell.allCases.count }() } + + fileprivate enum ItemIdentifier: Hashable + { + case action(ActionCell) + case device(String) + } public enum ActionCellLayout { @@ -85,8 +90,8 @@ class SwitchesViewController: UIViewController, // Action cells snapshot.appendItems([ - SwitchesViewController.ActionCell.allOn.rawValue, - SwitchesViewController.ActionCell.allOff.rawValue + .action(.allOn), + .action(.allOff) ], toSection: SwitchesViewController.actionCellsSectionIdentifier) _collectionViewDataSource.apply(snapshot, animatingDifferences: false) @@ -158,7 +163,10 @@ 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.appendItems( + self.devices.map { .device($0.serial) }, + toSection: SwitchesViewController.switchCellsSectionIdentifier + ) snapshot.reloadSections([Self.actionCellsSectionIdentifier]) _collectionViewDataSource.apply(snapshot, animatingDifferences: false) } @@ -168,9 +176,14 @@ class SwitchesViewController: UIViewController, { var snapshot = _collectionViewDataSource.snapshot() changedDevices.forEach { changedDevice in - if let existingDevice = (self.devices.first { $0.hashValue == changedDevice.hashValue }) { + if let existingDevice = self.devices.first(where: { + $0.serial == changedDevice.serial + }) { existingDevice.state = changedDevice.state - snapshot.reloadItems([ existingDevice.hashValue ]) + let identifier = ItemIdentifier.device(existingDevice.serial) + if snapshot.indexOfItem(identifier) != nil { + snapshot.reloadItems([identifier]) + } } } @@ -179,29 +192,36 @@ class SwitchesViewController: UIViewController, // MARK: UICollectionView - func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath, identifier: Int) -> UICollectionViewCell? + fileprivate func collectionView( + _ collectionView: UICollectionView, + cellForItemAt indexPath: IndexPath, + identifier: ItemIdentifier + ) -> UICollectionViewCell? { - if (indexPath.section == SwitchesViewController.actionCellsSectionIdentifier) { + switch identifier { + case .action(let action): let reuseID = SwitchesViewController.collectionViewActionCellReuseIdentifier let cell = collectionView.dequeueReusableCell(withReuseIdentifier: reuseID, for: indexPath) as! WemoActionCellView - cell.textLabel.text = ActionCell(rawValue: identifier)?.name().uppercased() + cell.textLabel.text = action.name().uppercased() + cell.accessibilityIdentifier = "action.\(action.rawValue)" + cell.accessibilityLabel = action.name() cell.enabled = (self.devices.count > 0) return cell - } else if (indexPath.section == SwitchesViewController.switchCellsSectionIdentifier) { + + case .device(let serial): let reuseID = SwitchesViewController.collectionViewDeviceSwitchCellReuseIdentifier let cell = collectionView.dequeueReusableCell(withReuseIdentifier: reuseID, for: indexPath) as! WemoDeviceCellView - if let device = (self.devices.first { $0.hashValue == identifier }) { + if let device = self.devices.first(where: { $0.serial == serial }) { cell.deviceName = device.name cell.toggled = (device.state == .on) cell.ordinal = indexPath.row + cell.accessibilityIdentifier = "device.\(device.serial)" } return cell } - - return nil } func collectionView(_ collectionView: UICollectionView, @@ -252,9 +272,12 @@ class SwitchesViewController: UIViewController, func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { - if (indexPath.section == SwitchesViewController.actionCellsSectionIdentifier) { - let tappedActionCell = ActionCell(rawValue: indexPath.item) - + guard let identifier = _collectionViewDataSource.itemIdentifier(for: indexPath) else { + return + } + + switch identifier { + case .action(let tappedActionCell): for cell in collectionView.visibleCells { if collectionView.indexPath(for: cell)?.section == SwitchesViewController.switchCellsSectionIdentifier { let switchCell = cell as! WemoDeviceCellView @@ -272,13 +295,17 @@ class SwitchesViewController: UIViewController, } self.delegate?.switchesViewControllerDidToggleDevices(self, devices: self.devices) - } else if (indexPath.section == SwitchesViewController.switchCellsSectionIdentifier) { - let cell = collectionView.cellForItem(at: indexPath) as! WemoDeviceCellView + + case .device(let serial): + guard + let cell = collectionView.cellForItem(at: indexPath) as? WemoDeviceCellView, + let device = self.devices.first(where: { $0.serial == serial }) + else { + return + } + cell.toggled = !cell.toggled - - let device = self.devices[indexPath.row] device.state = (cell.toggled ? .on : .off) - self.delegate?.switchesViewControllerDidToggleDevices(self, devices: [device]) } } diff --git a/XIONControlPanel/Controllers/VisualizationViewController.swift b/XIONControlPanel/Controllers/VisualizationViewController.swift index fb1e021..729df9c 100644 --- a/XIONControlPanel/Controllers/VisualizationViewController.swift +++ b/XIONControlPanel/Controllers/VisualizationViewController.swift @@ -11,7 +11,6 @@ import Foundation import GLKit import SceneKit import UIKit -import SceneKit let π = CGFloat(Double.pi) @@ -39,7 +38,7 @@ class VisualizationViewController: UIViewController, SCNSceneRendererDelegate override func loadView() { let opts = [SCNView.Option.preferredRenderingAPI.rawValue : SCNRenderingAPI.openGLES2.rawValue] - let view = SCNView(frame: UIScreen.main.bounds, options: opts) + let view = SCNView(frame: .zero, options: opts) view.backgroundColor = UIColor.black view.scene = _scene view.allowsCameraControl = false diff --git a/XIONControlPanel/Models/DeviceProtocol.swift b/XIONControlPanel/Models/DeviceProtocol.swift index d1628cb..bc3fc7b 100644 --- a/XIONControlPanel/Models/DeviceProtocol.swift +++ b/XIONControlPanel/Models/DeviceProtocol.swift @@ -35,14 +35,12 @@ extension Device where Self: Hashable { func hash(into hasher: inout Hasher) { - hasher.combine(self.name) - hasher.combine(self.type) hasher.combine(self.serial) } static func == (lhs: Self, rhs: Self) -> Bool { - return lhs.name == rhs.name && lhs.serial == rhs.serial + return lhs.serial == rhs.serial } } diff --git a/XIONControlPanel/Models/HubitatDevice.swift b/XIONControlPanel/Models/HubitatDevice.swift index 72461c6..042f176 100644 --- a/XIONControlPanel/Models/HubitatDevice.swift +++ b/XIONControlPanel/Models/HubitatDevice.swift @@ -43,7 +43,10 @@ extension HubitatDevice : Decodable name = try values.decode(String.self, forKey: .name) - let attributes = try values.nestedContainer(keyedBy: Self.AttributesKeys, forKey: .attributes) + let attributes = try values.nestedContainer( + keyedBy: Self.AttributesKeys.self, + forKey: .attributes + ) state = try attributes.decode(DeviceState.self, forKey: .switch) } } diff --git a/XIONControlPanel/Servers/HomeAssistantServer.swift b/XIONControlPanel/Servers/HomeAssistantServer.swift index ddcfa79..3d5768b 100644 --- a/XIONControlPanel/Servers/HomeAssistantServer.swift +++ b/XIONControlPanel/Servers/HomeAssistantServer.swift @@ -6,685 +6,1077 @@ // Copyright © 2025 XION. All rights reserved. // -import Combine import Foundation -class HomeAssistantServer: Server +final class HomeAssistantServer: Server { - var connectionStatus: ConnectionStatus = .disconnected + private(set) var connectionStatus: ConnectionStatus = .disconnected weak var delegate: ServerDelegate? - - // Configure: - private let authToken: String = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiI2ZTAwODI1N2Y4N2Q0OWIxYWRkN2ExNTBhOWRmNGZiZCIsImlhdCI6MTc1MDMwMDg5NiwiZXhwIjoyMDY1NjYwODk2fQ.KRf9GpRZdpW9Z_vkbL3sl74rgKS7eAyc8kO0a5jLTgg" - - // Configure: - private static let filterLabel = "control_panel" - - private let url: URL - private let liaison: Liaison - private var connectionContinuations: [CheckedContinuation] = [] - private var eventTask: Task<(), any Error>? = nil - private var knownDevices: Set = .init() - - typealias NoResult = [String: String] - - required init(_ url: URL) { - self.url = url - - let websocketURL = url.appending(component: "websocket").websocket() - self.liaison = Liaison(websocketURL: websocketURL) - } - - func connect(_ completion: @escaping ((any Error)?) -> Void) { - guard self.eventTask == nil else { - completion(nil) - return - } - - 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) - } - - func disconnect(_ completion: ((any Error)?) -> Void) { - self.eventTask?.cancel() - self.eventTask = nil - - Task { await liaison.disconnect() } - completion(nil) - } - - 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 { - await waitConnected() - - do { - guard case let .result(registryEntries) = try await liaison.sendMessage(RegistryEntriesMessage()) - else { fatalError() } - - let filteredEntries = registryEntries.filter { $0.labels.contains(Self.filterLabel) } - - 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) - } - - self.knownDevices = Set(devices) - - let anyDevices = devices.map { $0.eraseToAnyDevice() } - Task { @MainActor in completion(.success(anyDevices)) } - } catch { - Task { @MainActor in completion(.failure(error)) } + 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 ((any Error)?) -> Void) { - guard connectionStatus != .error else { - completion(ServerError.disconnected) - return - } - - Task { - await waitConnected() - + + 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 { - let entityID = device.serial - let service: CallServiceMessage.Service = switch state { - case .off: .turnOff - case .on: .turnOn - } - - 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 - } - + 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 liaison.sendMessage(message) - Task { @MainActor in completion(nil) } + _ = try await self.liaison.sendCommand(message) + completion(nil) } catch { - Task { @MainActor in completion(error) } + completion(error) } } } - - func responsibleForDevice(_ device: AnyDevice) -> Bool { - return knownDevices.contains { knownDevice in - knownDevice.serial == device.serial + + 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) } } - - // MARK: - - - @discardableResult - private func waitConnected() async -> Bool { - if connectionStatus == .connecting || connectionStatus == .disconnected { - return await withCheckedContinuation { continuation in - self.connectionContinuations.append(continuation) + #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) } - } else if connectionStatus == .error { - return false } - - return true } - - private func encounteredWebsocketError(_ error: any Error) { - print("Websocket Error: \(error)") - self.connectionStatus = .error - self.eventTask?.cancel() - self.eventTask = nil + + @MainActor + private func didConnect(generation: Int) + { + guard isCurrentConnection(generation) else { return } + + setConnectionStatus(.connected) + print("[HomeAssistant] Connected and subscribed") + startHeartbeat(generation: generation) } - - private func handleLiaisonEvent(_ event: Liaison.Event) async throws { - switch event { - case .partialEvent(let partialEvent, _): - switch partialEvent.type { - case .authRequired: - Task { await authenticate() } - case .authOK: - Task { - await subscribeToStateEvents() - didConnectToWebsocket() + + @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 } - case .event: - if let subEvent = partialEvent.event { - handleSubscriptionEvent(subEvent) - } - default: - print("Unhandled event: \(partialEvent)") + } + } + } + + 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 } - - case .pong: - didConnectToWebsocket() - case .disconnected(let error): - encounteredWebsocketError(error) - - case .error(let error): - encounteredWebsocketError(error) + try await Task.sleep(nanoseconds: Self.nanoseconds(0.1)) } + + if !wantsConnection { + throw HomeAssistantError.disconnected + } + throw HomeAssistantError.connectionTimedOut } - - 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 - case .other(_): .off + + 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 func subscribeToStateEvents() async { - do { - try await liaison.sendMessage(SubscribeEventsMessage()) - } catch { - print("Error subscribing to events: \(error)") - } + + 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 func didConnectToWebsocket() { - self.connectionStatus = .connected - - connectionContinuations.forEach { continuation in - continuation.resume(returning: true) - } - connectionContinuations.removeAll() - } - - private func authenticate() async { - do { - try await liaison.sendMessage(AuthMessage(token: authToken)) - } catch { - print("Error authenticating: \(error)") - } - } - - // MARK: - Liaison - - actor Liaison + + private static func configuredAccessToken() -> String? { - let websocketURL: URL - - private typealias Continuation = AsyncStream.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(_ message: M) async throws -> MessageResult { - 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.self, from: response) - if let result = decodedResponse.result { - continuation.resume(returning: .result(result)) - } else if let success = decodedResponse.success { - continuation.resume(with: .success(.success(success))) - } 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: outgoingMessage.id) - } + let environmentToken = ProcessInfo.processInfo.environment[ + "HOME_ASSISTANT_ACCESS_TOKEN" + ] + let bundledToken = Bundle.main.object( + forInfoDictionaryKey: "HomeAssistantAccessToken" + ) as? String - let encodedMessage = try JSONEncoder().encode(outgoingMessage) - let jsonString = String(data: encodedMessage, encoding: .utf8)! - try await websocketTask?.send(.string(jsonString)) - } - } - } - - fileprivate func partialEvents() throws -> AsyncStream { - 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() - } + return [environmentToken, bundledToken] + .compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) } + .first { !$0.isEmpty && !$0.hasPrefix("$(") } + } - 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)! - await handlePartialEvent(data: data) - case .data(let data): - await handlePartialEvent(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(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)") - } - } else { - yield(.partialEvent(event, data)) - } - } 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)") - } - } - - private func yield(_ event: Event) { - for (_, continuation) in continuations { - continuation.yield(event) - } - } - + private static func nanoseconds(_ seconds: TimeInterval) -> UInt64 + { + UInt64(max(0, seconds) * 1_000_000_000) + } + + // MARK: - Websocket liaison + + private actor Liaison + { enum Event { - case partialEvent(PartialEvent, Data) - case pong - - case disconnected(any Error) - case error(any Error) + case authRequired + case authOK + case authInvalid(String) + case subscription(SubscriptionEvent) + case unhandled(String) + case disconnected(Error) } - } - - // MARK: - Types - - struct PartialEvent: Decodable - { - let id: Int64? - let type: EventType - let event: SubscriptionEvent? - } - - struct Event: Decodable - { - let id: Int? - let type: EventType - let result: Result? - let success: Bool? - let error: ErrorResult? - } - - struct SubscriptionEvent: Decodable - { - let eventType: SubscriptionEventType - let data: SubscriptionEventData - - enum SubscriptionEventType: String, Codable + + private struct PendingRequest { - case stateChanged = "state_changed" + let connectionID: UUID + let continuation: CheckedContinuation + var timeoutTask: Task? } - - struct SubscriptionEventData: Decodable + + 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) { - let entityId: String - let newState: State - - enum CodingKeys: String, CodingKey - { - case entityId = "entity_id" - case newState = "new_state" + 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 eventType = "event_type" - case data + case entityID = "entity_id" + case newState = "new_state" } } - - enum EventType: String, Codable - { - case authRequired = "auth_required" - case authOK = "auth_ok" - case result = "result" - case event = "event" - } - - struct ErrorResult: Codable, Swift.Error - { - let code: String - let message: String - } - - enum ServerError: Swift.Error - { - case disconnected - } - - // MARK: - Result Types - -} -extension HomeAssistantDevice -{ - fileprivate convenience init(entity: RegistryEntriesMessage.Entry, state: DeviceState) { - 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, - type: type, - state: state - ) + enum CodingKeys: String, CodingKey + { + case eventType = "event_type" + case data } } -typealias MessageID = Int64? - -extension MessageID +private struct State: Decodable { - private static var counter: Int64 = 1 - static func next() -> Self { - OSAtomicIncrement64(&Self.counter) - return Self.counter - } -} + let entityID: String + let state: Value + let attributes: Attributes -struct State: Decodable -{ - let entityId: String - let state: State - - enum State: Decodable { + enum Value: Decodable + { case on case off case other(String) - - init(from decoder: Decoder) throws { + + init(from decoder: Decoder) throws + { let container = try decoder.singleValueContainer() - let stringValue = try container.decode(String.self) - - switch stringValue.lowercased() { + let value = try container.decode(String.self) + + switch value.lowercased() { case "on": self = .on case "off": self = .off default: - self = .other(stringValue) + self = .other(value) + } + } + + var deviceState: DeviceState? + { + switch self { + case .on: + return .on + case .off: + return .off + case .other: + return nil } } } - - struct Attributes: Decodable { + + struct Attributes: Decodable + { let friendlyName: String? - - enum CodingKeys: String, CodingKey { + + enum CodingKeys: String, CodingKey + { case friendlyName = "friendly_name" } } - - enum CodingKeys: String, CodingKey { - case entityId = "entity_id" + + enum CodingKeys: String, CodingKey + { + case entityID = "entity_id" case state + case attributes } } -// MARK: - Message Types - -typealias Null = String? - -enum MessageResult +private extension HomeAssistantDevice { - case result(R) - case success(Bool) + 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 + ) + } } -fileprivate protocol Message: Encodable +// MARK: - Outgoing messages + +private protocol CommandMessage: Encodable { - associatedtype Response: Decodable - - var id: MessageID { get set } + var id: Int64 { get set } var type: String { get } - - func nextMessageID() -> MessageID } -extension Message +private struct AuthMessage: Encodable { - func nextMessageID() -> MessageID { .next() } -} + let type = "auth" + let token: String -fileprivate struct RegistryEntriesMessage: Message -{ - var id: MessageID = nil - let type: String = "config/entity_registry/list" - - typealias Response = [Entry] - - struct Entry: Codable + enum CodingKeys: String, CodingKey { - let id: String - let entityId: String + 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 id + + enum CodingKeys: String, CodingKey + { case name case labels - case entityId = "entity_id" + 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) ?? [] } } } -fileprivate struct GetStatesMessage: Message +private struct GetStatesMessage: CommandMessage { - var id: MessageID = nil - let type: String = "get_states" - - typealias Response = [State] + var id: Int64 = 0 + let type = "get_states" } -fileprivate struct AuthMessage: Message +private struct SubscribeEventsMessage: CommandMessage { - var id: MessageID = nil - let type: String = "auth" - let token: String - - // No ID for auth messages - func nextMessageID() -> MessageID { nil } - - enum CodingKeys: String, CodingKey { - case id - case type - case token = "access_token" - } - - typealias Response = Null -} + var id: Int64 = 0 + let type = "subscribe_events" + let eventType = "state_changed" -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 +private struct PingMessage: CommandMessage { - var id: MessageID = nil - let type: String = "call_service" - + 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 = .switch) { + + 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 toggle - case turnOn = "turn_on" - case turnOff = "turn_off" + case turnOn = "turn_on" + case turnOff = "turn_off" } - + struct Target: Codable { let entityID: String - - enum CodingKeys: String, CodingKey { + + enum CodingKeys: String, CodingKey + { case entityID = "entity_id" } } - - typealias Response = Null } diff --git a/XIONControlPanel/Servers/ServerMultiplex.swift b/XIONControlPanel/Servers/ServerMultiplex.swift index 169d24a..2c91b90 100644 --- a/XIONControlPanel/Servers/ServerMultiplex.swift +++ b/XIONControlPanel/Servers/ServerMultiplex.swift @@ -8,11 +8,12 @@ import Foundation -protocol ServerMultiplexDelegate +protocol ServerMultiplexDelegate: AnyObject { func serverMultiplex(_ multiplex: ServerMultiplex, didAddDevices devices: [AnyDevice]) func serverMultiplex(_ multiplex: ServerMultiplex, devicesStateChanged devices: [AnyDevice]) func serverMultiplex(_ multiplex: ServerMultiplex, didReceiveAcknowledgementFromServer server: Server) + func serverMultiplexConnectionStatusDidChange(_ multiplex: ServerMultiplex) func serverMultiplex(_ multiplex: ServerMultiplex, didEncounterError error: Error) } @@ -23,10 +24,11 @@ enum ServerMultiplexError : Error class ServerMultiplex { - public var delegate: ServerMultiplexDelegate? + public weak var delegate: ServerMultiplexDelegate? public private(set) var devices = Set() private var servers: [Server] = [] + private var devicesByServer: [ObjectIdentifier: Set] = [:] public var numServers: Int { return servers.count } public func addServer(_ server: Server) @@ -73,31 +75,43 @@ class ServerMultiplex public func refreshDevices() { - self.servers.forEach { server in + servers.forEach { server in server.connect { error in if let error { print("Multiplexer: error connecting server \(server): \(error)") + self.handleError(forServer: server, error: error) } } - } - - self.servers.forEach { (server: Server) in + server.fetchDevices { (result: Result<[AnyDevice], Error>) in self.handleServerFetchResult(forServer: server, result: result) } } } + + public func disconnect() + { + servers.forEach { server in + server.disconnect { error in + if let error { + self.handleError(forServer: server, error: error) + } + } + } + } } extension ServerMultiplex { private func handleServerFetchResult(forServer server: Server, result: Result<[AnyDevice], Error>) { - switch result { - case .success(let devices): - handleDevicesChanged(forServer: server, devicesChanged: devices) - case .failure(let error): - handleError(forServer: server, error: error) + DispatchQueue.main.async { + switch result { + case .success(let devices): + self.handleDevicesChanged(forServer: server, devicesChanged: devices) + case .failure(let error): + self.handleError(forServer: server, error: error) + } } } @@ -107,28 +121,29 @@ extension ServerMultiplex self.delegate?.serverMultiplex(self, didReceiveAcknowledgementFromServer: server) // Then, optionally notify about new devices or device state changes + let serverID = ObjectIdentifier(server) + let previousDevices = devicesByServer[serverID] ?? [] let newDevicesSet = Set(devicesChanged) - let additions = newDevicesSet.subtracting(self.devices) + let additions = newDevicesSet.subtracting(previousDevices) + let removals = previousDevices.subtracting(newDevicesSet) let changed = newDevicesSet.filter { (device: AnyDevice) in - if let existing = (devices.first { $0.hashValue == device.hashValue }) { + if let existing = previousDevices.first(where: { $0.serial == device.serial }) { return existing.state != device.state } return false } - - self.devices = self.devices.union(newDevicesSet) - - if additions.count > 0 { - DispatchQueue.main.async { - self.delegate?.serverMultiplex(self, didAddDevices: Array(additions)) - } + + devicesByServer[serverID] = newDevicesSet + devices.subtract(previousDevices) + devices.formUnion(newDevicesSet) + + if !additions.isEmpty || !removals.isEmpty { + delegate?.serverMultiplex(self, didAddDevices: Array(additions)) } - if changed.count > 0 { - DispatchQueue.main.async { - self.delegate?.serverMultiplex(self, devicesStateChanged: Array(changed)) - } + if !changed.isEmpty { + delegate?.serverMultiplex(self, devicesStateChanged: Array(changed)) } } @@ -143,9 +158,21 @@ extension ServerMultiplex extension ServerMultiplex: ServerDelegate { func server(_ server: any Server, deviceChangedState subjectDevice: AnyDevice) { - guard let device = devices.first(where: { $0.serial == subjectDevice.serial }) else { return } - device.state = subjectDevice.state - - delegate?.serverMultiplex(self, devicesStateChanged: [device]) + DispatchQueue.main.async { + guard let device = self.devices.first(where: { + $0.serial == subjectDevice.serial + }) else { + return + } + + device.state = subjectDevice.state + self.delegate?.serverMultiplex(self, devicesStateChanged: [device]) + } + } + + func server(_ server: any Server, connectionStatusChanged status: ConnectionStatus) { + DispatchQueue.main.async { + self.delegate?.serverMultiplexConnectionStatusDidChange(self) + } } } diff --git a/XIONControlPanel/Servers/ServerProtocol.swift b/XIONControlPanel/Servers/ServerProtocol.swift index 5e1eafb..97d1a3a 100644 --- a/XIONControlPanel/Servers/ServerProtocol.swift +++ b/XIONControlPanel/Servers/ServerProtocol.swift @@ -8,7 +8,7 @@ import Foundation -enum ConnectionStatus +enum ConnectionStatus: Equatable { case disconnected case connecting @@ -19,6 +19,7 @@ enum ConnectionStatus protocol ServerDelegate: AnyObject { func server(_ server: Server, deviceChangedState: AnyDevice) + func server(_ server: Server, connectionStatusChanged status: ConnectionStatus) } protocol Server: AnyObject @@ -44,4 +45,3 @@ protocol Server: AnyObject /// Returns true if this is a device this server is responsible for func responsibleForDevice(_ device: AnyDevice) -> Bool } - diff --git a/XIONControlPanel/Servers/WemoServer.swift b/XIONControlPanel/Servers/WemoServer.swift index c15e900..e2927b3 100644 --- a/XIONControlPanel/Servers/WemoServer.swift +++ b/XIONControlPanel/Servers/WemoServer.swift @@ -42,9 +42,8 @@ class WemoServer : Server self.connectionStatus = .connecting let op = ConnectOperation(baseURL: self.baseURL, session: _urlSession) - weak var weakOp = op - op.completionBlock = { - guard let strongOp = weakOp else { completion(nil) ; return } + op.completionBlock = { [weak op] in + guard let strongOp = op else { completion(nil) ; return } if let error = strongOp.error { self._logError("Error connecting to server", error: error) self.connectionStatus = .disconnected @@ -69,14 +68,18 @@ class WemoServer : Server func fetchDevices(_ completion: @escaping (Result<[AnyDevice], Error>) -> Void) { let op = FetchDevicesOperation(baseURL: self.baseURL, session: _urlSession) - op.completionBlock = { [unowned op] in + op.completionBlock = { [weak op] in + guard let op else { + completion(.failure(ConnectionError.unknown)) + return + } + if let error = op.error { self._logError("Error fetching devices", error: error) completion(.failure(error)) - } else { - self.devices = op.devices + return } - + self.devices = op.devices completion(.success(self.devices.map { AnyDevice($0) })) } @@ -98,9 +101,8 @@ class WemoServer : Server { if connectionStatus == .connected, let device = findDevice(device) { let op = ToggleDeviceOperation(baseURL: self.baseURL, session: _urlSession, device: device, state: state) - weak var weakOp = op - op.completionBlock = { - guard let strongOp = weakOp else { completion(nil) ; return } + op.completionBlock = { [weak op] in + guard let strongOp = op else { completion(nil) ; return } if let error = strongOp.error { self._logError("Error toggling device", error: error) } @@ -132,7 +134,7 @@ class WemoServer : Server } } -internal class WemoOperation : Operation +internal class WemoOperation : Operation, @unchecked Sendable { var baseURL: URL var session: URLSession @@ -146,7 +148,7 @@ internal class WemoOperation : Operation } } -internal class ConnectOperation : WemoOperation +internal class ConnectOperation : WemoOperation, @unchecked Sendable { override func main() { @@ -164,7 +166,7 @@ internal class ConnectOperation : WemoOperation } } -internal class FetchDevicesOperation : WemoOperation +internal class FetchDevicesOperation : WemoOperation, @unchecked Sendable { private(set) var devices: [WemoDevice] = [] @@ -201,7 +203,7 @@ internal class FetchDevicesOperation : WemoOperation } } -internal class ToggleDeviceOperation : WemoOperation +internal class ToggleDeviceOperation : WemoOperation, @unchecked Sendable { var device: WemoDevice var state: DeviceState diff --git a/XIONControlPanel/SupportingFiles/Info.plist b/XIONControlPanel/SupportingFiles/Info.plist index 9e4a9df..746bc6a 100644 --- a/XIONControlPanel/SupportingFiles/Info.plist +++ b/XIONControlPanel/SupportingFiles/Info.plist @@ -20,6 +20,8 @@ ???? CFBundleVersion $(CURRENT_PROJECT_VERSION) + HomeAssistantAccessToken + $(HOME_ASSISTANT_ACCESS_TOKEN) LSRequiresIPhoneOS NSAppTransportSecurity diff --git a/XIONControlPanel/Views/WemoCellView.swift b/XIONControlPanel/Views/WemoCellView.swift index e476c8f..cd9c799 100644 --- a/XIONControlPanel/Views/WemoCellView.swift +++ b/XIONControlPanel/Views/WemoCellView.swift @@ -19,6 +19,7 @@ open class WemoCellView: UICollectionViewCell { super.init(frame: frame) + isAccessibilityElement = true self.contentView.backgroundColor = WemoCellView.disabledBackgroundColor _selectionOverlayView.backgroundColor = UIColor.clear @@ -130,6 +131,7 @@ open class WemoDeviceCellView: WemoCellView didSet { _nameLabel.text = self.deviceName.uppercased() + accessibilityLabel = deviceName self.setNeedsLayout() } } @@ -154,6 +156,8 @@ open class WemoDeviceCellView: WemoCellView didSet { _indicator.status = toggled + accessibilityValue = toggled ? "On" : "Off" + accessibilityTraits = toggled ? [.button, .selected] : .button if (toggled) { self.contentView.backgroundColor = WemoCellView.enabledBackgroundColor @@ -176,6 +180,7 @@ open class WemoActionCellView: WemoCellView { super.init(frame: frame) + accessibilityTraits = .button self.textLabel.font = UIFont(name: "Orbitron-Medium", size: 21.0) self.textLabel.textColor = UIColor.white self.textLabel.textAlignment = .center diff --git a/XIONControlPanelTests/HomeAssistantIntegrationTests.swift b/XIONControlPanelTests/HomeAssistantIntegrationTests.swift new file mode 100644 index 0000000..99d4b00 --- /dev/null +++ b/XIONControlPanelTests/HomeAssistantIntegrationTests.swift @@ -0,0 +1,184 @@ +import XCTest +@testable import XION + +final class HomeAssistantIntegrationTests: XCTestCase, ServerDelegate +{ + private var expectedEntityID: String? + private var expectedState: DeviceState? + private var stateExpectation: XCTestExpectation? + private var connectionErrorExpectation: XCTestExpectation? + private var reconnectionExpectation: XCTestExpectation? + + @MainActor + func testLiveAsteroidsRoundTrip() async throws + { + guard ProcessInfo.processInfo.environment["RUN_LIVE_HOME_ASSISTANT_TESTS"] == "1" else { + throw XCTSkip("Set RUN_LIVE_HOME_ASSISTANT_TESTS=1 to run live integration tests.") + } + guard + let token = ProcessInfo.processInfo.environment["HOME_ASSISTANT_ACCESS_TOKEN"], + !token.isEmpty + else { + XCTFail("HOME_ASSISTANT_ACCESS_TOKEN is required for the live test.") + return + } + + let server = HomeAssistantServer(URL(string: "https://ha.xaibatsu.com/api")!) + server.delegate = self + + try await connect(server) + let devices = try await fetchDevices(from: server) + guard let asteroids = devices.first(where: { + $0.name.caseInsensitiveCompare("Asteroids") == .orderedSame + }) else { + server.disconnect { _ in } + XCTFail("The Home Assistant response did not include Asteroids.") + return + } + + let originalState = asteroids.state + let testState: DeviceState = originalState == .on ? .off : .on + var restorationRequired = false + + do { + restorationRequired = true + try await toggle( + asteroids, + to: testState, + using: server, + expectationDescription: "Asteroids changed to \(testState.rawValue)" + ) + + try await toggle( + asteroids, + to: originalState, + using: server, + expectationDescription: "Asteroids restored to \(originalState.rawValue)" + ) + restorationRequired = false + + let connectionError = expectation( + description: "The simulated socket failure was detected" + ) + let reconnection = expectation( + description: "The server reconnected after the socket failure" + ) + connectionErrorExpectation = connectionError + reconnectionExpectation = reconnection + server.simulateConnectionFailureForTesting() + await fulfillment( + of: [connectionError, reconnection], + timeout: 8, + enforceOrder: true + ) + + for _ in 0..<3 { + server.disconnect { error in + XCTAssertNil(error) + } + try await connect(server) + } + + let reconnectedDevices = try await fetchDevices(from: server) + XCTAssertEqual( + reconnectedDevices.first(where: { $0.serial == asteroids.serial })?.state, + originalState + ) + } catch { + if restorationRequired { + try? await toggleWithoutWaiting(asteroids, to: originalState, using: server) + } + server.disconnect { _ in } + throw error + } + + server.disconnect { error in + XCTAssertNil(error) + } + XCTAssertEqual(server.connectionStatus, .disconnected) + } + + func server(_ server: Server, deviceChangedState device: AnyDevice) + { + guard + device.serial == expectedEntityID, + device.state == expectedState + else { + return + } + + stateExpectation?.fulfill() + stateExpectation = nil + } + + func server(_ server: Server, connectionStatusChanged status: ConnectionStatus) + { + if status == .error { + connectionErrorExpectation?.fulfill() + connectionErrorExpectation = nil + } else if status == .connected, connectionErrorExpectation == nil { + reconnectionExpectation?.fulfill() + reconnectionExpectation = nil + } + } + + @MainActor + private func connect(_ server: HomeAssistantServer) async throws + { + try await withCheckedThrowingContinuation { + (continuation: CheckedContinuation) in + server.connect { error in + if let error { + continuation.resume(throwing: error) + } else { + continuation.resume() + } + } + } + } + + @MainActor + private func fetchDevices(from server: HomeAssistantServer) async throws -> [AnyDevice] + { + try await withCheckedThrowingContinuation { continuation in + server.fetchDevices { result in + continuation.resume(with: result) + } + } + } + + @MainActor + private func toggle( + _ device: AnyDevice, + to state: DeviceState, + using server: HomeAssistantServer, + expectationDescription: String + ) async throws { + expectedEntityID = device.serial + expectedState = state + let expectation = expectation(description: expectationDescription) + stateExpectation = expectation + + try await toggleWithoutWaiting(device, to: state, using: server) + await fulfillment(of: [expectation], timeout: 10) + XCTAssertEqual(device.state, state) + } + + @MainActor + private func toggleWithoutWaiting( + _ device: AnyDevice, + to state: DeviceState, + using server: HomeAssistantServer + ) async throws { + try await withCheckedThrowingContinuation { + (continuation: CheckedContinuation) in + server.toggleDevice(device, state: state) { error in + if let error { + continuation.resume(throwing: error) + } else { + continuation.resume() + } + } + } + } +}