Improve Home Assistant connection reliability

This commit is contained in:
2026-07-27 16:23:40 -07:00
parent b7ec774efd
commit f3341ee064
16 changed files with 1696 additions and 697 deletions

View File

@@ -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. This is an iOS control panel for the XION/Xaibatsu arcade. It connects to Home
The app is designed for XION but could work with any WEMO-enabled household by Assistant's websocket API, displays entities with the `control_panel` label, and
changing only one line of code. 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 Configuration
[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.
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
```

View File

@@ -43,8 +43,20 @@
CD815B96241DC44D00E2FA97 /* HubitatDevice.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD815B95241DC44D00E2FA97 /* HubitatDevice.swift */; }; CD815B96241DC44D00E2FA97 /* HubitatDevice.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD815B95241DC44D00E2FA97 /* HubitatDevice.swift */; };
CDA64C242E03AE6400E9B07E /* HomeAssistantServer.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDA64C232E03AE6100E9B07E /* HomeAssistantServer.swift */; }; CDA64C242E03AE6400E9B07E /* HomeAssistantServer.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDA64C232E03AE6100E9B07E /* HomeAssistantServer.swift */; };
CDA64C262E03EA1400E9B07E /* HomeAssistantDevice.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDA64C252E03EA1000E9B07E /* HomeAssistantDevice.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 */ /* 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 */ /* Begin PBXFileReference section */
0C20E2B51C33514100BA789B /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; }; 0C20E2B51C33514100BA789B /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
0C20E2BA1C33529700BA789B /* MainViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MainViewController.swift; sourceTree = "<group>"; }; 0C20E2BA1C33529700BA789B /* MainViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MainViewController.swift; sourceTree = "<group>"; };
@@ -87,6 +99,9 @@
CD815B95241DC44D00E2FA97 /* HubitatDevice.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HubitatDevice.swift; sourceTree = "<group>"; }; CD815B95241DC44D00E2FA97 /* HubitatDevice.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HubitatDevice.swift; sourceTree = "<group>"; };
CDA64C232E03AE6100E9B07E /* HomeAssistantServer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeAssistantServer.swift; sourceTree = "<group>"; }; CDA64C232E03AE6100E9B07E /* HomeAssistantServer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeAssistantServer.swift; sourceTree = "<group>"; };
CDA64C252E03EA1000E9B07E /* HomeAssistantDevice.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeAssistantDevice.swift; sourceTree = "<group>"; }; CDA64C252E03EA1000E9B07E /* HomeAssistantDevice.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeAssistantDevice.swift; sourceTree = "<group>"; };
AA0000010000000000000000 /* HomeAssistantIntegrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeAssistantIntegrationTests.swift; sourceTree = "<group>"; };
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 */ /* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */ /* Begin PBXFrameworksBuildPhase section */
@@ -97,6 +112,14 @@
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
AA0000080000000000000000 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
AA0000050000000000000000 /* XCTest.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */ /* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */ /* Begin PBXGroup section */
@@ -166,6 +189,7 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
0C3CAEA61C3350C800B856AD /* XIONControlPanel */, 0C3CAEA61C3350C800B856AD /* XIONControlPanel */,
AA0000060000000000000000 /* XIONControlPanelTests */,
0C3CAEA51C3350C800B856AD /* Products */, 0C3CAEA51C3350C800B856AD /* Products */,
); );
sourceTree = "<group>"; sourceTree = "<group>";
@@ -174,6 +198,7 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
0C3CAEA41C3350C800B856AD /* XION.app */, 0C3CAEA41C3350C800B856AD /* XION.app */,
AA0000020000000000000000 /* XIONControlPanelTests.xctest */,
); );
name = Products; name = Products;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -244,6 +269,14 @@
path = Servers; path = Servers;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
AA0000060000000000000000 /* XIONControlPanelTests */ = {
isa = PBXGroup;
children = (
AA0000010000000000000000 /* HomeAssistantIntegrationTests.swift */,
);
path = XIONControlPanelTests;
sourceTree = "<group>";
};
/* End PBXGroup section */ /* End PBXGroup section */
/* Begin PBXNativeTarget section */ /* Begin PBXNativeTarget section */
@@ -264,6 +297,24 @@
productReference = 0C3CAEA41C3350C800B856AD /* XION.app */; productReference = 0C3CAEA41C3350C800B856AD /* XION.app */;
productType = "com.apple.product-type.application"; 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 */ /* End PBXNativeTarget section */
/* Begin PBXProject section */ /* Begin PBXProject section */
@@ -280,6 +331,12 @@
LastSwiftMigration = 1010; LastSwiftMigration = 1010;
ProvisioningStyle = Automatic; ProvisioningStyle = Automatic;
}; };
AA00000A0000000000000000 = {
CreatedOnToolsVersion = 26.5;
DevelopmentTeam = DQQH5H6GBD;
ProvisioningStyle = Automatic;
TestTargetID = 0C3CAEA31C3350C800B856AD;
};
}; };
}; };
buildConfigurationList = 0C3CAE9F1C3350C800B856AD /* Build configuration list for PBXProject "XIONControlPanel" */; buildConfigurationList = 0C3CAE9F1C3350C800B856AD /* Build configuration list for PBXProject "XIONControlPanel" */;
@@ -297,6 +354,7 @@
projectRoot = ""; projectRoot = "";
targets = ( targets = (
0C3CAEA31C3350C800B856AD /* XIONControlPanel */, 0C3CAEA31C3350C800B856AD /* XIONControlPanel */,
AA00000A0000000000000000 /* XIONControlPanelTests */,
); );
}; };
/* End PBXProject section */ /* End PBXProject section */
@@ -321,6 +379,13 @@
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
AA0000090000000000000000 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */ /* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */
@@ -355,8 +420,24 @@
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
AA0000070000000000000000 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
AA0000030000000000000000 /* HomeAssistantIntegrationTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */ /* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
AA00000C0000000000000000 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 0C3CAEA31C3350C800B856AD /* XIONControlPanel */;
targetProxy = AA00000B0000000000000000 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin PBXVariantGroup section */ /* Begin PBXVariantGroup section */
0C3CAEB01C3350C800B856AD /* LaunchScreen.storyboard */ = { 0C3CAEB01C3350C800B856AD /* LaunchScreen.storyboard */ = {
isa = PBXVariantGroup; isa = PBXVariantGroup;
@@ -485,6 +566,7 @@
CURRENT_PROJECT_VERSION = 3; CURRENT_PROJECT_VERSION = 3;
DEVELOPMENT_TEAM = DQQH5H6GBD; DEVELOPMENT_TEAM = DQQH5H6GBD;
INFOPLIST_FILE = "$(SRCROOT)/XIONControlPanel/SupportingFiles/Info.plist"; INFOPLIST_FILE = "$(SRCROOT)/XIONControlPanel/SupportingFiles/Info.plist";
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 2.0; MARKETING_VERSION = 2.0;
PRODUCT_BUNDLE_IDENTIFIER = com.zanneth.XIONControlPanel; PRODUCT_BUNDLE_IDENTIFIER = com.zanneth.XIONControlPanel;
@@ -492,6 +574,7 @@
PROVISIONING_PROFILE = ""; PROVISIONING_PROFILE = "";
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";
SWIFT_OBJC_BRIDGING_HEADER = "XIONControlPanel/SupportingFiles/XIONControlPanel-Bridging-Header.h"; SWIFT_OBJC_BRIDGING_HEADER = "XIONControlPanel/SupportingFiles/XIONControlPanel-Bridging-Header.h";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2"; TARGETED_DEVICE_FAMILY = "1,2";
}; };
@@ -507,6 +590,7 @@
CURRENT_PROJECT_VERSION = 3; CURRENT_PROJECT_VERSION = 3;
DEVELOPMENT_TEAM = DQQH5H6GBD; DEVELOPMENT_TEAM = DQQH5H6GBD;
INFOPLIST_FILE = "$(SRCROOT)/XIONControlPanel/SupportingFiles/Info.plist"; INFOPLIST_FILE = "$(SRCROOT)/XIONControlPanel/SupportingFiles/Info.plist";
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 2.0; MARKETING_VERSION = 2.0;
PRODUCT_BUNDLE_IDENTIFIER = com.zanneth.XIONControlPanel; PRODUCT_BUNDLE_IDENTIFIER = com.zanneth.XIONControlPanel;
@@ -519,6 +603,51 @@
}; };
name = Release; 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 */ /* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */ /* Begin XCConfigurationList section */
@@ -540,6 +669,15 @@
defaultConfigurationIsVisible = 0; defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release; defaultConfigurationName = Release;
}; };
AA00000F0000000000000000 /* Build configuration list for PBXNativeTarget "XIONControlPanelTests" */ = {
isa = XCConfigurationList;
buildConfigurations = (
AA00000D0000000000000000 /* Debug */,
AA00000E0000000000000000 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */ /* End XCConfigurationList section */
}; };
rootObject = 0C3CAE9C1C3350C800B856AD /* Project object */; rootObject = 0C3CAE9C1C3350C800B856AD /* Project object */;

View File

@@ -0,0 +1,138 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2650"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
buildArchitectures = "Automatic">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "0C3CAEA31C3350C800B856AD"
BuildableName = "XION.app"
BlueprintName = "XIONControlPanel"
ReferencedContainer = "container:XIONControlPanel.xcodeproj">
</BuildableReference>
</BuildActionEntry>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "NO"
buildForProfiling = "NO"
buildForArchiving = "NO"
buildForAnalyzing = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "AA00000A0000000000000000"
BuildableName = "XIONControlPanelTests.xctest"
BlueprintName = "XIONControlPanelTests"
ReferencedContainer = "container:XIONControlPanel.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "0C3CAEA31C3350C800B856AD"
BuildableName = "XION.app"
BlueprintName = "XIONControlPanel"
ReferencedContainer = "container:XIONControlPanel.xcodeproj">
</BuildableReference>
</MacroExpansion>
<Testables>
<TestableReference
skipped = "NO"
parallelizable = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "AA00000A0000000000000000"
BuildableName = "XIONControlPanelTests.xctest"
BlueprintName = "XIONControlPanelTests"
ReferencedContainer = "container:XIONControlPanel.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
<EnvironmentVariables>
<EnvironmentVariable
key = "HOME_ASSISTANT_ACCESS_TOKEN"
value = "$(HOME_ASSISTANT_ACCESS_TOKEN)"
isEnabled = "YES">
</EnvironmentVariable>
<EnvironmentVariable
key = "RUN_LIVE_HOME_ASSISTANT_TESTS"
value = "$(RUN_LIVE_HOME_ASSISTANT_TESTS)"
isEnabled = "YES">
</EnvironmentVariable>
</EnvironmentVariables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "0C3CAEA31C3350C800B856AD"
BuildableName = "XION.app"
BlueprintName = "XIONControlPanel"
ReferencedContainer = "container:XIONControlPanel.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
<EnvironmentVariables>
<EnvironmentVariable
key = "HOME_ASSISTANT_ACCESS_TOKEN"
value = "$(HOME_ASSISTANT_ACCESS_TOKEN)"
isEnabled = "YES">
</EnvironmentVariable>
<EnvironmentVariable
key = "RUN_LIVE_HOME_ASSISTANT_TESTS"
value = "$(RUN_LIVE_HOME_ASSISTANT_TESTS)"
isEnabled = "YES">
</EnvironmentVariable>
</EnvironmentVariables>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "0C3CAEA31C3350C800B856AD"
BuildableName = "XION.app"
BlueprintName = "XIONControlPanel"
ReferencedContainer = "container:XIONControlPanel.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@@ -17,7 +17,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate
func application(_ application: UIApplication, func application(_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool
{ {
self.window = UIWindow(frame: UIScreen.main.bounds) self.window = UIWindow()
self.window?.rootViewController = self.mainViewController self.window?.rootViewController = self.mainViewController
self.window?.makeKeyAndVisible() self.window?.makeKeyAndVisible()
@@ -26,12 +26,17 @@ class AppDelegate: UIResponder, UIApplicationDelegate
func applicationDidBecomeActive(_ application: UIApplication) func applicationDidBecomeActive(_ application: UIApplication)
{ {
self.mainViewController.viewDidAppear(false) mainViewController.applicationDidBecomeActive()
}
func applicationWillResignActive(_ application: UIApplication)
{
mainViewController.applicationWillResignActive()
} }
func applicationDidEnterBackground(_ application: UIApplication) func applicationDidEnterBackground(_ application: UIApplication)
{ {
self.mainViewController.viewDidDisappear(false) mainViewController.applicationWillResignActive()
} }
func application(_ application: UIApplication, func application(_ application: UIApplication,

View File

@@ -13,7 +13,9 @@ 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 _headerView: HeaderView = HeaderView() 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 _switchesController: SwitchesViewController = SwitchesViewController()
fileprivate var _lightsController: SwitchesViewController = SwitchesViewController() fileprivate var _lightsController: SwitchesViewController = SwitchesViewController()
@@ -58,6 +60,7 @@ class MainViewController: UIViewController, SwitchesViewControllerDelegate, Serv
self.view.addSubview(_headerView) self.view.addSubview(_headerView)
_updateConnectivityStatus(.disconnected) _updateConnectivityStatus(.disconnected)
_updateSizeClassPresentation()
} }
override func viewDidLayoutSubviews() override func viewDidLayoutSubviews()
@@ -80,61 +83,68 @@ class MainViewController: UIViewController, SwitchesViewControllerDelegate, Serv
_headerView.frame = headerBounds _headerView.frame = headerBounds
let visualizationFrame = CGRect( if _visualizationShouldBeVisible() {
x: bodyBounds.origin.x, let visualizationFrame = CGRect(
y: bodyBounds.origin.y, x: bodyBounds.origin.x,
width: rint(0.5 * bodyBounds.size.width), y: bodyBounds.origin.y,
height: bodyBounds.size.height / 1.5 width: rint(0.5 * bodyBounds.size.width),
) height: bodyBounds.size.height / 1.5
_visualizationController.view.frame = visualizationFrame )
_visualizationController.view.frame = visualizationFrame
let lightsControllerFrame = CGRect( let lightsControllerFrame = CGRect(
x: bodyBounds.origin.x, x: bodyBounds.origin.x,
y: visualizationFrame.maxY, y: visualizationFrame.maxY,
width: visualizationFrame.width, width: visualizationFrame.width,
height: bodyBounds.height - visualizationFrame.height height: bodyBounds.height - visualizationFrame.height
) )
_lightsController.view.frame = lightsControllerFrame.insetBy(dx: 18.0, dy: 0.0) _lightsController.view.frame = lightsControllerFrame.insetBy(
dx: 18.0,
dy: 0.0
)
var switchesOriginX: CGFloat = 0.0 _switchesController.view.frame = CGRect(
var switchesWidth: CGFloat = 0.0 x: visualizationFrame.maxX,
if (_visualizationShouldBeVisible()) { y: bodyBounds.origin.y,
switchesOriginX = visualizationFrame.maxX width: bodyBounds.width - visualizationFrame.width,
switchesWidth = bodyBounds.size.width - visualizationFrame.size.width height: bodyBounds.height
)
} else { } else {
switchesOriginX = 0.0 let sectionSpacing: CGFloat = 8
switchesWidth = bodyBounds.size.width 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( _updateSizeClassPresentation()
x: switchesOriginX,
y: bodyBounds.origin.y,
width: switchesWidth,
height: bodyBounds.size.height
)
_switchesController.view.frame = switchesControllerFrame
} }
override func viewDidAppear(_ animated: Bool) override func viewDidAppear(_ animated: Bool)
{ {
super.viewDidAppear(animated) super.viewDidAppear(animated)
UIApplication.shared.isIdleTimerDisabled = true _isViewVisible = true
_updateActiveState()
_headerView.xionLogoView.beginAnimating()
if _serverMultiplex.devices.count == 0 {
// Do initial refresh
_updateConnectivityStatus(.connecting)
_serverMultiplex.refreshDevices()
_startUpdatingDevices()
}
} }
override func viewDidDisappear(_ animated: Bool) override func viewDidDisappear(_ animated: Bool)
{ {
super.viewDidDisappear(animated) super.viewDidDisappear(animated)
UIApplication.shared.isIdleTimerDisabled = false
_isViewVisible = false
_updateActiveState()
} }
override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator)
@@ -155,7 +165,21 @@ class MainViewController: UIViewController, SwitchesViewControllerDelegate, Serv
_updateVisualization(true) _updateVisualization(true)
for device in devices { 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 _isApplicationActive = true
_updateActiveState()
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()
}
}
} }
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 // MARK: Server Multiplex Delegate
@@ -232,6 +275,7 @@ class MainViewController: UIViewController, SwitchesViewControllerDelegate, Serv
{ {
_switchesController.devicesStateChanged(devices.switches) _switchesController.devicesStateChanged(devices.switches)
_lightsController.devicesStateChanged(devices.lights) _lightsController.devicesStateChanged(devices.lights)
_updateVisualization(true)
} }
func serverMultiplex(_ multiplex: ServerMultiplex, didReceiveAcknowledgementFromServer server: Server) func serverMultiplex(_ multiplex: ServerMultiplex, didReceiveAcknowledgementFromServer server: Server)
@@ -241,6 +285,11 @@ class MainViewController: UIViewController, SwitchesViewControllerDelegate, Serv
_updateConnectivityStatus(groupConnectionStatus) _updateConnectivityStatus(groupConnectionStatus)
} }
func serverMultiplexConnectionStatusDidChange(_ multiplex: ServerMultiplex)
{
_updateConnectivityStatus(multiplex.groupConnectionStatus())
}
func serverMultiplex(_ multiplex: ServerMultiplex, didEncounterError error: Error) func serverMultiplex(_ multiplex: ServerMultiplex, didEncounterError error: Error)
{ {
_updateConnectivityStatus(.error) _updateConnectivityStatus(.error)

View File

@@ -10,7 +10,7 @@ import Darwin
import Foundation import Foundation
import UIKit import UIKit
protocol SwitchesViewControllerDelegate: class protocol SwitchesViewControllerDelegate: AnyObject
{ {
func switchesViewControllerDidToggleDevices(_ controller: SwitchesViewController, devices: [AnyDevice]) func switchesViewControllerDidToggleDevices(_ controller: SwitchesViewController, devices: [AnyDevice])
} }
@@ -29,9 +29,8 @@ class SwitchesViewController: UIViewController,
fileprivate var _collectionView: UICollectionView = UICollectionView(frame: CGRect.zero, fileprivate var _collectionView: UICollectionView = UICollectionView(frame: CGRect.zero,
collectionViewLayout: UICollectionViewFlowLayout()) collectionViewLayout: UICollectionViewFlowLayout())
fileprivate var _collectionViewDataSource: UICollectionViewDiffableDataSource<Int, Int>! fileprivate var _collectionViewDataSource:
UICollectionViewDiffableDataSource<Int, ItemIdentifier>!
fileprivate var _currentDevicesHash: Int = 0
fileprivate let _label = UILabel(frame: .zero) fileprivate let _label = UILabel(frame: .zero)
@@ -42,7 +41,7 @@ class SwitchesViewController: UIViewController,
static fileprivate let actionCellsSectionIdentifier = 0 static fileprivate let actionCellsSectionIdentifier = 0
static fileprivate let switchCellsSectionIdentifier = 1 static fileprivate let switchCellsSectionIdentifier = 1
fileprivate enum ActionCell: Int, CaseIterable fileprivate enum ActionCell: Int, CaseIterable, Hashable
{ {
case allOn case allOn
case allOff case allOff
@@ -60,6 +59,12 @@ class SwitchesViewController: UIViewController,
static let count: Int = { return ActionCell.allCases.count }() static let count: Int = { return ActionCell.allCases.count }()
} }
fileprivate enum ItemIdentifier: Hashable
{
case action(ActionCell)
case device(String)
}
public enum ActionCellLayout public enum ActionCellLayout
{ {
case horizontal case horizontal
@@ -85,8 +90,8 @@ class SwitchesViewController: UIViewController,
// Action cells // Action cells
snapshot.appendItems([ snapshot.appendItems([
SwitchesViewController.ActionCell.allOn.rawValue, .action(.allOn),
SwitchesViewController.ActionCell.allOff.rawValue .action(.allOff)
], toSection: SwitchesViewController.actionCellsSectionIdentifier) ], toSection: SwitchesViewController.actionCellsSectionIdentifier)
_collectionViewDataSource.apply(snapshot, animatingDifferences: false) _collectionViewDataSource.apply(snapshot, animatingDifferences: false)
@@ -158,7 +163,10 @@ class SwitchesViewController: UIViewController,
var snapshot = _collectionViewDataSource.snapshot() var snapshot = _collectionViewDataSource.snapshot()
snapshot.deleteItems(snapshot.itemIdentifiers(inSection: SwitchesViewController.switchCellsSectionIdentifier)) 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]) snapshot.reloadSections([Self.actionCellsSectionIdentifier])
_collectionViewDataSource.apply(snapshot, animatingDifferences: false) _collectionViewDataSource.apply(snapshot, animatingDifferences: false)
} }
@@ -168,9 +176,14 @@ class SwitchesViewController: UIViewController,
{ {
var snapshot = _collectionViewDataSource.snapshot() var snapshot = _collectionViewDataSource.snapshot()
changedDevices.forEach { changedDevice in 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 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 // 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 reuseID = SwitchesViewController.collectionViewActionCellReuseIdentifier
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: reuseID, for: indexPath) as! WemoActionCellView 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) cell.enabled = (self.devices.count > 0)
return cell return cell
} else if (indexPath.section == SwitchesViewController.switchCellsSectionIdentifier) {
case .device(let serial):
let reuseID = SwitchesViewController.collectionViewDeviceSwitchCellReuseIdentifier let reuseID = SwitchesViewController.collectionViewDeviceSwitchCellReuseIdentifier
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: reuseID, for: indexPath) as! WemoDeviceCellView 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.deviceName = device.name
cell.toggled = (device.state == .on) cell.toggled = (device.state == .on)
cell.ordinal = indexPath.row cell.ordinal = indexPath.row
cell.accessibilityIdentifier = "device.\(device.serial)"
} }
return cell return cell
} }
return nil
} }
func collectionView(_ collectionView: UICollectionView, func collectionView(_ collectionView: UICollectionView,
@@ -252,9 +272,12 @@ class SwitchesViewController: UIViewController,
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath)
{ {
if (indexPath.section == SwitchesViewController.actionCellsSectionIdentifier) { guard let identifier = _collectionViewDataSource.itemIdentifier(for: indexPath) else {
let tappedActionCell = ActionCell(rawValue: indexPath.item) return
}
switch identifier {
case .action(let tappedActionCell):
for cell in collectionView.visibleCells { for cell in collectionView.visibleCells {
if collectionView.indexPath(for: cell)?.section == SwitchesViewController.switchCellsSectionIdentifier { if collectionView.indexPath(for: cell)?.section == SwitchesViewController.switchCellsSectionIdentifier {
let switchCell = cell as! WemoDeviceCellView let switchCell = cell as! WemoDeviceCellView
@@ -272,13 +295,17 @@ class SwitchesViewController: UIViewController,
} }
self.delegate?.switchesViewControllerDidToggleDevices(self, devices: self.devices) 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 cell.toggled = !cell.toggled
let device = self.devices[indexPath.row]
device.state = (cell.toggled ? .on : .off) device.state = (cell.toggled ? .on : .off)
self.delegate?.switchesViewControllerDidToggleDevices(self, devices: [device]) self.delegate?.switchesViewControllerDidToggleDevices(self, devices: [device])
} }
} }

View File

@@ -11,7 +11,6 @@ import Foundation
import GLKit import GLKit
import SceneKit import SceneKit
import UIKit import UIKit
import SceneKit
let π = CGFloat(Double.pi) let π = CGFloat(Double.pi)
@@ -39,7 +38,7 @@ class VisualizationViewController: UIViewController, SCNSceneRendererDelegate
override func loadView() override func loadView()
{ {
let opts = [SCNView.Option.preferredRenderingAPI.rawValue : SCNRenderingAPI.openGLES2.rawValue] 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.backgroundColor = UIColor.black
view.scene = _scene view.scene = _scene
view.allowsCameraControl = false view.allowsCameraControl = false

View File

@@ -35,14 +35,12 @@ extension Device where Self: Hashable
{ {
func hash(into hasher: inout Hasher) func hash(into hasher: inout Hasher)
{ {
hasher.combine(self.name)
hasher.combine(self.type)
hasher.combine(self.serial) hasher.combine(self.serial)
} }
static func == (lhs: Self, rhs: Self) -> Bool static func == (lhs: Self, rhs: Self) -> Bool
{ {
return lhs.name == rhs.name && lhs.serial == rhs.serial return lhs.serial == rhs.serial
} }
} }

View File

@@ -43,7 +43,10 @@ extension HubitatDevice : Decodable
name = try values.decode(String.self, forKey: .name) 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) state = try attributes.decode(DeviceState.self, forKey: .switch)
} }
} }

File diff suppressed because it is too large Load Diff

View File

@@ -8,11 +8,12 @@
import Foundation import Foundation
protocol ServerMultiplexDelegate protocol ServerMultiplexDelegate: AnyObject
{ {
func serverMultiplex(_ multiplex: ServerMultiplex, didAddDevices devices: [AnyDevice]) func serverMultiplex(_ multiplex: ServerMultiplex, didAddDevices devices: [AnyDevice])
func serverMultiplex(_ multiplex: ServerMultiplex, devicesStateChanged devices: [AnyDevice]) func serverMultiplex(_ multiplex: ServerMultiplex, devicesStateChanged devices: [AnyDevice])
func serverMultiplex(_ multiplex: ServerMultiplex, didReceiveAcknowledgementFromServer server: Server) func serverMultiplex(_ multiplex: ServerMultiplex, didReceiveAcknowledgementFromServer server: Server)
func serverMultiplexConnectionStatusDidChange(_ multiplex: ServerMultiplex)
func serverMultiplex(_ multiplex: ServerMultiplex, didEncounterError error: Error) func serverMultiplex(_ multiplex: ServerMultiplex, didEncounterError error: Error)
} }
@@ -23,10 +24,11 @@ enum ServerMultiplexError : Error
class ServerMultiplex class ServerMultiplex
{ {
public var delegate: ServerMultiplexDelegate? public weak var delegate: ServerMultiplexDelegate?
public private(set) var devices = Set<AnyDevice>() public private(set) var devices = Set<AnyDevice>()
private var servers: [Server] = [] private var servers: [Server] = []
private var devicesByServer: [ObjectIdentifier: Set<AnyDevice>] = [:]
public var numServers: Int { return servers.count } public var numServers: Int { return servers.count }
public func addServer(_ server: Server) public func addServer(_ server: Server)
@@ -73,31 +75,43 @@ class ServerMultiplex
public func refreshDevices() public func refreshDevices()
{ {
self.servers.forEach { server in servers.forEach { server in
server.connect { error in server.connect { error in
if let error { if let error {
print("Multiplexer: error connecting server \(server): \(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 server.fetchDevices { (result: Result<[AnyDevice], Error>) in
self.handleServerFetchResult(forServer: server, result: result) 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 extension ServerMultiplex
{ {
private func handleServerFetchResult(forServer server: Server, result: Result<[AnyDevice], Error>) private func handleServerFetchResult(forServer server: Server, result: Result<[AnyDevice], Error>)
{ {
switch result { DispatchQueue.main.async {
case .success(let devices): switch result {
handleDevicesChanged(forServer: server, devicesChanged: devices) case .success(let devices):
case .failure(let error): self.handleDevicesChanged(forServer: server, devicesChanged: devices)
handleError(forServer: server, error: error) case .failure(let error):
self.handleError(forServer: server, error: error)
}
} }
} }
@@ -107,28 +121,29 @@ extension ServerMultiplex
self.delegate?.serverMultiplex(self, didReceiveAcknowledgementFromServer: server) self.delegate?.serverMultiplex(self, didReceiveAcknowledgementFromServer: server)
// Then, optionally notify about new devices or device state changes // Then, optionally notify about new devices or device state changes
let serverID = ObjectIdentifier(server)
let previousDevices = devicesByServer[serverID] ?? []
let newDevicesSet = Set<AnyDevice>(devicesChanged) let newDevicesSet = Set<AnyDevice>(devicesChanged)
let additions = newDevicesSet.subtracting(self.devices) let additions = newDevicesSet.subtracting(previousDevices)
let removals = previousDevices.subtracting(newDevicesSet)
let changed = newDevicesSet.filter { (device: AnyDevice) in 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 existing.state != device.state
} }
return false return false
} }
self.devices = self.devices.union(newDevicesSet) devicesByServer[serverID] = newDevicesSet
devices.subtract(previousDevices)
devices.formUnion(newDevicesSet)
if additions.count > 0 { if !additions.isEmpty || !removals.isEmpty {
DispatchQueue.main.async { delegate?.serverMultiplex(self, didAddDevices: Array(additions))
self.delegate?.serverMultiplex(self, didAddDevices: Array(additions))
}
} }
if changed.count > 0 { if !changed.isEmpty {
DispatchQueue.main.async { delegate?.serverMultiplex(self, devicesStateChanged: Array(changed))
self.delegate?.serverMultiplex(self, devicesStateChanged: Array(changed))
}
} }
} }
@@ -143,9 +158,21 @@ extension ServerMultiplex
extension ServerMultiplex: ServerDelegate extension ServerMultiplex: ServerDelegate
{ {
func server(_ server: any Server, deviceChangedState subjectDevice: AnyDevice) { func server(_ server: any Server, deviceChangedState subjectDevice: AnyDevice) {
guard let device = devices.first(where: { $0.serial == subjectDevice.serial }) else { return } DispatchQueue.main.async {
device.state = subjectDevice.state guard let device = self.devices.first(where: {
$0.serial == subjectDevice.serial
}) else {
return
}
delegate?.serverMultiplex(self, devicesStateChanged: [device]) 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)
}
} }
} }

View File

@@ -8,7 +8,7 @@
import Foundation import Foundation
enum ConnectionStatus enum ConnectionStatus: Equatable
{ {
case disconnected case disconnected
case connecting case connecting
@@ -19,6 +19,7 @@ enum ConnectionStatus
protocol ServerDelegate: AnyObject protocol ServerDelegate: AnyObject
{ {
func server(_ server: Server, deviceChangedState: AnyDevice) func server(_ server: Server, deviceChangedState: AnyDevice)
func server(_ server: Server, connectionStatusChanged status: ConnectionStatus)
} }
protocol Server: AnyObject protocol Server: AnyObject
@@ -44,4 +45,3 @@ protocol Server: AnyObject
/// Returns true if this is a device this server is responsible for /// Returns true if this is a device this server is responsible for
func responsibleForDevice(_ device: AnyDevice) -> Bool func responsibleForDevice(_ device: AnyDevice) -> Bool
} }

View File

@@ -42,9 +42,8 @@ class WemoServer : Server
self.connectionStatus = .connecting self.connectionStatus = .connecting
let op = ConnectOperation(baseURL: self.baseURL, session: _urlSession) let op = ConnectOperation(baseURL: self.baseURL, session: _urlSession)
weak var weakOp = op op.completionBlock = { [weak op] in
op.completionBlock = { guard let strongOp = op else { completion(nil) ; return }
guard let strongOp = weakOp else { completion(nil) ; return }
if let error = strongOp.error { if let error = strongOp.error {
self._logError("Error connecting to server", error: error) self._logError("Error connecting to server", error: error)
self.connectionStatus = .disconnected self.connectionStatus = .disconnected
@@ -69,12 +68,16 @@ class WemoServer : Server
func fetchDevices(_ completion: @escaping (Result<[AnyDevice], Error>) -> Void) func fetchDevices(_ completion: @escaping (Result<[AnyDevice], Error>) -> Void)
{ {
let op = FetchDevicesOperation(baseURL: self.baseURL, session: _urlSession) 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 { if let error = op.error {
self._logError("Error fetching devices", error: error) self._logError("Error fetching devices", error: error)
completion(.failure(error)) completion(.failure(error))
} else { return
self.devices = op.devices
} }
self.devices = op.devices self.devices = op.devices
@@ -98,9 +101,8 @@ class WemoServer : Server
{ {
if connectionStatus == .connected, let device = findDevice(device) { if connectionStatus == .connected, let device = findDevice(device) {
let op = ToggleDeviceOperation(baseURL: self.baseURL, session: _urlSession, device: device, state: state) let op = ToggleDeviceOperation(baseURL: self.baseURL, session: _urlSession, device: device, state: state)
weak var weakOp = op op.completionBlock = { [weak op] in
op.completionBlock = { guard let strongOp = op else { completion(nil) ; return }
guard let strongOp = weakOp else { completion(nil) ; return }
if let error = strongOp.error { if let error = strongOp.error {
self._logError("Error toggling device", error: 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 baseURL: URL
var session: URLSession var session: URLSession
@@ -146,7 +148,7 @@ internal class WemoOperation : Operation
} }
} }
internal class ConnectOperation : WemoOperation internal class ConnectOperation : WemoOperation, @unchecked Sendable
{ {
override func main() 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] = [] 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 device: WemoDevice
var state: DeviceState var state: DeviceState

View File

@@ -20,6 +20,8 @@
<string>????</string> <string>????</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string> <string>$(CURRENT_PROJECT_VERSION)</string>
<key>HomeAssistantAccessToken</key>
<string>$(HOME_ASSISTANT_ACCESS_TOKEN)</string>
<key>LSRequiresIPhoneOS</key> <key>LSRequiresIPhoneOS</key>
<true/> <true/>
<key>NSAppTransportSecurity</key> <key>NSAppTransportSecurity</key>

View File

@@ -19,6 +19,7 @@ open class WemoCellView: UICollectionViewCell
{ {
super.init(frame: frame) super.init(frame: frame)
isAccessibilityElement = true
self.contentView.backgroundColor = WemoCellView.disabledBackgroundColor self.contentView.backgroundColor = WemoCellView.disabledBackgroundColor
_selectionOverlayView.backgroundColor = UIColor.clear _selectionOverlayView.backgroundColor = UIColor.clear
@@ -130,6 +131,7 @@ open class WemoDeviceCellView: WemoCellView
didSet didSet
{ {
_nameLabel.text = self.deviceName.uppercased() _nameLabel.text = self.deviceName.uppercased()
accessibilityLabel = deviceName
self.setNeedsLayout() self.setNeedsLayout()
} }
} }
@@ -154,6 +156,8 @@ open class WemoDeviceCellView: WemoCellView
didSet didSet
{ {
_indicator.status = toggled _indicator.status = toggled
accessibilityValue = toggled ? "On" : "Off"
accessibilityTraits = toggled ? [.button, .selected] : .button
if (toggled) { if (toggled) {
self.contentView.backgroundColor = WemoCellView.enabledBackgroundColor self.contentView.backgroundColor = WemoCellView.enabledBackgroundColor
@@ -176,6 +180,7 @@ open class WemoActionCellView: WemoCellView
{ {
super.init(frame: frame) super.init(frame: frame)
accessibilityTraits = .button
self.textLabel.font = UIFont(name: "Orbitron-Medium", size: 21.0) self.textLabel.font = UIFont(name: "Orbitron-Medium", size: 21.0)
self.textLabel.textColor = UIColor.white self.textLabel.textColor = UIColor.white
self.textLabel.textAlignment = .center self.textLabel.textAlignment = .center

View File

@@ -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<Void, Error>) 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<Void, Error>) in
server.toggleDevice(device, state: state) { error in
if let error {
continuation.resume(throwing: error)
} else {
continuation.resume()
}
}
}
}
}