Improve Home Assistant connection reliability
This commit is contained in:
46
README.md
46
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.
|
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
|
||||||
|
```
|
||||||
|
|||||||
@@ -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 */;
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,6 +83,7 @@ class MainViewController: UIViewController, SwitchesViewControllerDelegate, Serv
|
|||||||
|
|
||||||
_headerView.frame = headerBounds
|
_headerView.frame = headerBounds
|
||||||
|
|
||||||
|
if _visualizationShouldBeVisible() {
|
||||||
let visualizationFrame = CGRect(
|
let visualizationFrame = CGRect(
|
||||||
x: bodyBounds.origin.x,
|
x: bodyBounds.origin.x,
|
||||||
y: bodyBounds.origin.y,
|
y: bodyBounds.origin.y,
|
||||||
@@ -94,47 +98,53 @@ class MainViewController: UIViewController, SwitchesViewControllerDelegate, Serv
|
|||||||
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)
|
||||||
|
|||||||
@@ -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])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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,17 +75,27 @@ 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
server.fetchDevices { (result: Result<[AnyDevice], Error>) in
|
||||||
|
self.handleServerFetchResult(forServer: server, result: result)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
self.servers.forEach { (server: Server) in
|
public func disconnect()
|
||||||
server.fetchDevices { (result: Result<[AnyDevice], Error>) in
|
{
|
||||||
self.handleServerFetchResult(forServer: server, result: result)
|
servers.forEach { server in
|
||||||
|
server.disconnect { error in
|
||||||
|
if let error {
|
||||||
|
self.handleError(forServer: server, error: error)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -93,11 +105,13 @@ extension ServerMultiplex
|
|||||||
{
|
{
|
||||||
private func handleServerFetchResult(forServer server: Server, result: Result<[AnyDevice], Error>)
|
private func handleServerFetchResult(forServer server: Server, result: Result<[AnyDevice], Error>)
|
||||||
{
|
{
|
||||||
|
DispatchQueue.main.async {
|
||||||
switch result {
|
switch result {
|
||||||
case .success(let devices):
|
case .success(let devices):
|
||||||
handleDevicesChanged(forServer: server, devicesChanged: devices)
|
self.handleDevicesChanged(forServer: server, devicesChanged: devices)
|
||||||
case .failure(let error):
|
case .failure(let error):
|
||||||
handleError(forServer: server, error: 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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
184
XIONControlPanelTests/HomeAssistantIntegrationTests.swift
Normal file
184
XIONControlPanelTests/HomeAssistantIntegrationTests.swift
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user