Merge branch 'buzzert/hubitat' into 'master'
Hubitat Support See merge request zanneth/XIONControlPanel!1
This commit is contained in:
@@ -36,6 +36,11 @@
|
||||
0CAA327D1C361ECA00B353CF /* StandardError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CAA327C1C361ECA00B353CF /* StandardError.swift */; };
|
||||
0CF0F8961C362D8F00FE7752 /* NSURLAdditions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CF0F8951C362D8F00FE7752 /* NSURLAdditions.swift */; };
|
||||
0CF9A48E1C35E9C800276F13 /* SwitchIndicatorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CF9A48D1C35E9C800276F13 /* SwitchIndicatorView.swift */; };
|
||||
CD815B8E241C6F4000E2FA97 /* ServerProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD815B8D241C6F4000E2FA97 /* ServerProtocol.swift */; };
|
||||
CD815B90241C7CAC00E2FA97 /* DeviceProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD815B8F241C7CAC00E2FA97 /* DeviceProtocol.swift */; };
|
||||
CD815B92241DA28B00E2FA97 /* ServerMultiplex.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD815B91241DA28B00E2FA97 /* ServerMultiplex.swift */; };
|
||||
CD815B94241DC16700E2FA97 /* HubitatServer.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD815B93241DC16700E2FA97 /* HubitatServer.swift */; };
|
||||
CD815B96241DC44D00E2FA97 /* HubitatDevice.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD815B95241DC44D00E2FA97 /* HubitatDevice.swift */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
@@ -73,6 +78,11 @@
|
||||
0CAA327C1C361ECA00B353CF /* StandardError.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StandardError.swift; sourceTree = "<group>"; };
|
||||
0CF0F8951C362D8F00FE7752 /* NSURLAdditions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NSURLAdditions.swift; sourceTree = "<group>"; };
|
||||
0CF9A48D1C35E9C800276F13 /* SwitchIndicatorView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SwitchIndicatorView.swift; sourceTree = "<group>"; };
|
||||
CD815B8D241C6F4000E2FA97 /* ServerProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerProtocol.swift; sourceTree = "<group>"; };
|
||||
CD815B8F241C7CAC00E2FA97 /* DeviceProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceProtocol.swift; sourceTree = "<group>"; };
|
||||
CD815B91241DA28B00E2FA97 /* ServerMultiplex.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerMultiplex.swift; sourceTree = "<group>"; };
|
||||
CD815B93241DC16700E2FA97 /* HubitatServer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HubitatServer.swift; sourceTree = "<group>"; };
|
||||
CD815B95241DC44D00E2FA97 /* HubitatDevice.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HubitatDevice.swift; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
@@ -112,7 +122,6 @@
|
||||
0C20E2BA1C33529700BA789B /* MainViewController.swift */,
|
||||
0C41EF491C34B51500AF847F /* SwitchesViewController.swift */,
|
||||
0C20E2BC1C33534F00BA789B /* VisualizationViewController.swift */,
|
||||
0CAA32781C361A2400B353CF /* WemoServer.swift */,
|
||||
);
|
||||
path = Controllers;
|
||||
sourceTree = "<group>";
|
||||
@@ -120,6 +129,8 @@
|
||||
0C20E2B71C33514100BA789B /* Models */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
CD815B8F241C7CAC00E2FA97 /* DeviceProtocol.swift */,
|
||||
CD815B95241DC44D00E2FA97 /* HubitatDevice.swift */,
|
||||
0C4314FF1C35B1E700164559 /* WemoDevice.swift */,
|
||||
);
|
||||
path = Models;
|
||||
@@ -169,6 +180,7 @@
|
||||
0C20E2B61C33514100BA789B /* Controllers */,
|
||||
0C41EF4B1C34B81F00AF847F /* External */,
|
||||
0C20E2B71C33514100BA789B /* Models */,
|
||||
CD815B8C241C6F2C00E2FA97 /* Servers */,
|
||||
0C63A8741C3373BC007E4B52 /* Shaders */,
|
||||
0C20E2C81C336F2E00BA789B /* Resources */,
|
||||
0C63A8791C33C622007E4B52 /* Utilities */,
|
||||
@@ -215,6 +227,17 @@
|
||||
path = Utilities;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
CD815B8C241C6F2C00E2FA97 /* Servers */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
CD815B91241DA28B00E2FA97 /* ServerMultiplex.swift */,
|
||||
CD815B8D241C6F4000E2FA97 /* ServerProtocol.swift */,
|
||||
CD815B93241DC16700E2FA97 /* HubitatServer.swift */,
|
||||
0CAA32781C361A2400B353CF /* WemoServer.swift */,
|
||||
);
|
||||
path = Servers;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
@@ -242,13 +265,14 @@
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
LastSwiftUpdateCheck = 0720;
|
||||
LastUpgradeCheck = 0720;
|
||||
LastUpgradeCheck = 1200;
|
||||
ORGANIZATIONNAME = XION;
|
||||
TargetAttributes = {
|
||||
0C3CAEA31C3350C800B856AD = {
|
||||
CreatedOnToolsVersion = 7.2;
|
||||
DevelopmentTeam = F7W5R35V7L;
|
||||
LastSwiftMigration = 1010;
|
||||
ProvisioningStyle = Manual;
|
||||
};
|
||||
};
|
||||
};
|
||||
@@ -301,10 +325,13 @@
|
||||
0C41EF4E1C34B81F00AF847F /* KSIRingView.m in Sources */,
|
||||
0CAA327B1C361CBE00B353CF /* NSErrorAdditions.swift in Sources */,
|
||||
0CF0F8961C362D8F00FE7752 /* NSURLAdditions.swift in Sources */,
|
||||
CD815B94241DC16700E2FA97 /* HubitatServer.swift in Sources */,
|
||||
0CAA32791C361A2400B353CF /* WemoServer.swift in Sources */,
|
||||
0C41EF561C35ABCD00AF847F /* ConnectionStatusView.swift in Sources */,
|
||||
0C63A87D1C33C655007E4B52 /* NSValue+XIONAdditions.m in Sources */,
|
||||
CD815B96241DC44D00E2FA97 /* HubitatDevice.swift in Sources */,
|
||||
0C20E2BD1C33534F00BA789B /* VisualizationViewController.swift in Sources */,
|
||||
CD815B90241C7CAC00E2FA97 /* DeviceProtocol.swift in Sources */,
|
||||
0C41EF4A1C34B51500AF847F /* SwitchesViewController.swift in Sources */,
|
||||
0C20E2B91C33514100BA789B /* AppDelegate.swift in Sources */,
|
||||
0C20E2BB1C33529700BA789B /* MainViewController.swift in Sources */,
|
||||
@@ -312,6 +339,8 @@
|
||||
0C21329B1C4DDC3E001E8F08 /* Semaphore.swift in Sources */,
|
||||
0C41EF541C34BC9800AF847F /* XIONLogoView.swift in Sources */,
|
||||
0C4315061C35E67600164559 /* WemoCellView.swift in Sources */,
|
||||
CD815B92241DA28B00E2FA97 /* ServerMultiplex.swift in Sources */,
|
||||
CD815B8E241C6F4000E2FA97 /* ServerProtocol.swift in Sources */,
|
||||
0CAA327D1C361ECA00B353CF /* StandardError.swift in Sources */,
|
||||
0CF9A48E1C35E9C800276F13 /* SwitchIndicatorView.swift in Sources */,
|
||||
0C4315001C35B1E700164559 /* WemoDevice.swift in Sources */,
|
||||
@@ -336,17 +365,29 @@
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
||||
CLANG_CXX_LIBRARY = "libc++";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
|
||||
@@ -380,17 +421,29 @@
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
||||
CLANG_CXX_LIBRARY = "libc++";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
|
||||
@@ -408,6 +461,7 @@
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
VALIDATE_PRODUCT = YES;
|
||||
};
|
||||
@@ -418,14 +472,18 @@
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
|
||||
CODE_SIGN_IDENTITY = "Apple Distribution: Charles Magahern (F7W5R35V7L)";
|
||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "Apple Distribution: Charles Magahern (F7W5R35V7L)";
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
CURRENT_PROJECT_VERSION = 3;
|
||||
DEVELOPMENT_TEAM = F7W5R35V7L;
|
||||
INFOPLIST_FILE = "$(SRCROOT)/XIONControlPanel/SupportingFiles/Info.plist";
|
||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
||||
MARKETING_VERSION = 2.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.zanneth.XIONControlPanel;
|
||||
PRODUCT_NAME = XION;
|
||||
PROVISIONING_PROFILE = "";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "Octahedron II";
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "XIONControlPanel/SupportingFiles/XIONControlPanel-Bridging-Header.h";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
@@ -437,14 +495,18 @@
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
|
||||
CODE_SIGN_IDENTITY = "Apple Distribution: Charles Magahern (F7W5R35V7L)";
|
||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "Apple Distribution: Charles Magahern (F7W5R35V7L)";
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
CURRENT_PROJECT_VERSION = 3;
|
||||
DEVELOPMENT_TEAM = F7W5R35V7L;
|
||||
INFOPLIST_FILE = "$(SRCROOT)/XIONControlPanel/SupportingFiles/Info.plist";
|
||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
||||
MARKETING_VERSION = 2.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.zanneth.XIONControlPanel;
|
||||
PRODUCT_NAME = XION;
|
||||
PROVISIONING_PROFILE = "";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "Octahedron II";
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "XIONControlPanel/SupportingFiles/XIONControlPanel-Bridging-Header.h";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
|
||||
@@ -8,9 +8,9 @@
|
||||
|
||||
import UIKit
|
||||
|
||||
class MainViewController: UIViewController, SwitchesViewControllerDelegate
|
||||
class MainViewController: UIViewController, SwitchesViewControllerDelegate, ServerMultiplexDelegate
|
||||
{
|
||||
fileprivate var _server: WemoServer
|
||||
fileprivate var _serverMultiplex: ServerMultiplex = ServerMultiplex()
|
||||
fileprivate var _visualizationController: VisualizationViewController = VisualizationViewController()
|
||||
fileprivate var _switchesController: SwitchesViewController = SwitchesViewController()
|
||||
fileprivate var _headerView: HeaderView = HeaderView()
|
||||
@@ -18,10 +18,11 @@ class MainViewController: UIViewController, SwitchesViewControllerDelegate
|
||||
|
||||
override init(nibName nibNameOrNil: String?, bundle nibBundleOrNil: Bundle?)
|
||||
{
|
||||
let url = URL(string: "http://midna.xionsf.com:5000")!
|
||||
_server = WemoServer(url)
|
||||
|
||||
super.init(nibName: nibNameOrNil, bundle: nibBundleOrNil)
|
||||
|
||||
_serverMultiplex.delegate = self
|
||||
_serverMultiplex.addServer(HubitatServer(URL(string: "https://midna.xionsf.com:6000/apps/api/1/")!))
|
||||
_serverMultiplex.addServer(WemoServer(URL(string: "http://midna.xionsf.com:5000")!))
|
||||
}
|
||||
|
||||
required init?(coder aDecoder: NSCoder)
|
||||
@@ -104,16 +105,11 @@ class MainViewController: UIViewController, SwitchesViewControllerDelegate
|
||||
|
||||
_headerView.xionLogoView.beginAnimating()
|
||||
|
||||
if (!_server.connected) {
|
||||
if _serverMultiplex.devices.count == 0 {
|
||||
// Do initial refresh
|
||||
_updateConnectivityStatus(.connecting)
|
||||
_server.connect { (error: Error?) -> Void in
|
||||
if (error == nil) {
|
||||
self._reloadDevices()
|
||||
self._startUpdatingDevices()
|
||||
} else {
|
||||
self._updateConnectivityStatus(.error)
|
||||
}
|
||||
}
|
||||
_serverMultiplex.refreshDevices()
|
||||
_startUpdatingDevices()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,25 +130,14 @@ class MainViewController: UIViewController, SwitchesViewControllerDelegate
|
||||
return true
|
||||
}
|
||||
|
||||
// MARK: API
|
||||
|
||||
var devices: [WemoDevice] = []
|
||||
{
|
||||
didSet
|
||||
{
|
||||
_switchesController.devices = devices
|
||||
_updateVisualization(false)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: SwitchesViewControllerDelegate
|
||||
|
||||
func switchesViewControllerDidToggleDevices(_ controller: SwitchesViewController, devices: [WemoDevice])
|
||||
func switchesViewControllerDidToggleDevices(_ controller: SwitchesViewController, devices: [AnyDevice])
|
||||
{
|
||||
_updateVisualization(true)
|
||||
|
||||
for device in devices {
|
||||
_server.toggleDevice(device, state: device.state, completion: { (error: Error?) -> Void in })
|
||||
_serverMultiplex.toggleDeviceState(device, state: device.state, completion: { (error: Error?) -> Void in })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,14 +164,10 @@ class MainViewController: UIViewController, SwitchesViewControllerDelegate
|
||||
|
||||
internal func _updateVisualization(_ animated: Bool)
|
||||
{
|
||||
var activatedDevicesCount = 0
|
||||
for device in self.devices {
|
||||
if (device.state == .on) {
|
||||
activatedDevicesCount += 1
|
||||
}
|
||||
}
|
||||
let activatedDevicesCount = _serverMultiplex.devices.filter { $0.state == .on }.count
|
||||
let totalDeviceCount = _serverMultiplex.devices.count
|
||||
|
||||
let percentageActivated = (self.devices.count > 0 ? Float(activatedDevicesCount) / Float(self.devices.count) : 0.0)
|
||||
let percentageActivated = (totalDeviceCount > 0 ? Float(activatedDevicesCount) / Float(totalDeviceCount) : 0.0)
|
||||
if (percentageActivated != _visualizationController.percentActivated) {
|
||||
_visualizationController.setPercentActivated(percentageActivated, animated: animated)
|
||||
}
|
||||
@@ -201,21 +182,6 @@ class MainViewController: UIViewController, SwitchesViewControllerDelegate
|
||||
}
|
||||
}
|
||||
|
||||
internal func _reloadDevices()
|
||||
{
|
||||
_server.fetchDevices({ (devices: [WemoDevice], error: Error?) -> Void in
|
||||
DispatchQueue.main.async { () -> Void in
|
||||
if (error == nil) {
|
||||
self.devices = devices
|
||||
self._updateConnectivityStatus(.connected)
|
||||
} else {
|
||||
self.devices = []
|
||||
self._updateConnectivityStatus(.error)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
internal func _startUpdatingDevices()
|
||||
{
|
||||
_updateDevices = true
|
||||
@@ -223,7 +189,7 @@ class MainViewController: UIViewController, SwitchesViewControllerDelegate
|
||||
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._reloadDevices()
|
||||
self._serverMultiplex.refreshDevices()
|
||||
self._startUpdatingDevices()
|
||||
}
|
||||
}
|
||||
@@ -233,4 +199,30 @@ class MainViewController: UIViewController, SwitchesViewControllerDelegate
|
||||
{
|
||||
_updateDevices = false
|
||||
}
|
||||
|
||||
// MARK: Server Multiplex Delegate
|
||||
|
||||
func serverMultiplex(_ multiplex: ServerMultiplex, didAddDevices devices: [AnyDevice])
|
||||
{
|
||||
_switchesController.devices = Array(multiplex.devices)
|
||||
_updateVisualization(false)
|
||||
|
||||
// Update connection status too, if applicable
|
||||
if _serverMultiplex.numServers == _serverMultiplex.numberOfConnectedServers() {
|
||||
_updateConnectivityStatus(.connected)
|
||||
}
|
||||
}
|
||||
|
||||
func serverMultiplex(_ multiplex: ServerMultiplex, devicesStateChanged devices: [AnyDevice])
|
||||
{
|
||||
_switchesController.devicesStateChanged(devices)
|
||||
}
|
||||
|
||||
func serverMultiplex(_ multiplex: ServerMultiplex, didEncounterError error: Error)
|
||||
{
|
||||
_updateConnectivityStatus(.error)
|
||||
|
||||
var stderr = StandardErrorOutputStream()
|
||||
print(error.localizedDescription, to: &stderr)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,29 +12,29 @@ import UIKit
|
||||
|
||||
protocol SwitchesViewControllerDelegate: class
|
||||
{
|
||||
func switchesViewControllerDidToggleDevices(_ controller: SwitchesViewController, devices: [WemoDevice])
|
||||
}
|
||||
|
||||
extension SwitchesViewControllerDelegate
|
||||
{
|
||||
func switchesViewControllerDidToggleDevices(_ controller: SwitchesViewController, devices: [WemoDevice]) {}
|
||||
func switchesViewControllerDidToggleDevices(_ controller: SwitchesViewController, devices: [AnyDevice])
|
||||
}
|
||||
|
||||
class SwitchesViewController: UIViewController,
|
||||
UICollectionViewDataSource,
|
||||
UICollectionViewDelegateFlowLayout
|
||||
{
|
||||
weak var delegate: SwitchesViewControllerDelegate?
|
||||
weak var delegate: SwitchesViewControllerDelegate?
|
||||
|
||||
fileprivate var _collectionView: UICollectionView = UICollectionView(frame: CGRect.zero,
|
||||
collectionViewLayout: UICollectionViewFlowLayout())
|
||||
fileprivate var _currentDevicesHash: Int = 0
|
||||
fileprivate var _collectionView: UICollectionView = UICollectionView(frame: CGRect.zero,
|
||||
collectionViewLayout: UICollectionViewFlowLayout())
|
||||
|
||||
fileprivate var _collectionViewDataSource: UICollectionViewDiffableDataSource<Int, Int>!
|
||||
|
||||
fileprivate var _currentDevicesHash: Int = 0
|
||||
|
||||
static fileprivate let collectionViewDeviceSwitchCellReuseIdentifier = "DeviceSwitchReuseID"
|
||||
static fileprivate let collectionViewActionCellReuseIdentifier = "ActionCellReuseID"
|
||||
static fileprivate let collectionViewCellsSpacing: CGFloat = 5.0
|
||||
|
||||
fileprivate enum ActionCell: Int
|
||||
static fileprivate let actionCellsSectionIdentifier = 0
|
||||
static fileprivate let switchCellsSectionIdentifier = 1
|
||||
|
||||
fileprivate enum ActionCell: Int, CaseIterable
|
||||
{
|
||||
case allOn
|
||||
case allOff
|
||||
@@ -49,13 +49,37 @@ class SwitchesViewController: UIViewController,
|
||||
}
|
||||
}
|
||||
|
||||
static let count: Int = {
|
||||
var max = 0
|
||||
while let _ = ActionCell(rawValue: max) { max += 1 }
|
||||
return max
|
||||
}()
|
||||
static let count: Int = { return ActionCell.allCases.count }()
|
||||
}
|
||||
|
||||
override init(nibName nibNameOrNil: String?, bundle nibBundleOrNil: Bundle?)
|
||||
{
|
||||
super.init(nibName: nibNameOrNil, bundle: nibBundleOrNil)
|
||||
|
||||
_collectionViewDataSource = UICollectionViewDiffableDataSource(collectionView: _collectionView, cellProvider: { (collectionView, indexPath, identifier) -> UICollectionViewCell? in
|
||||
return self.collectionView(collectionView, cellForItemAt: indexPath, identifier: identifier)
|
||||
})
|
||||
|
||||
// Initialize data source
|
||||
var snapshot = _collectionViewDataSource.snapshot()
|
||||
|
||||
// Sections
|
||||
snapshot.appendSections([
|
||||
SwitchesViewController.actionCellsSectionIdentifier,
|
||||
SwitchesViewController.switchCellsSectionIdentifier,
|
||||
])
|
||||
|
||||
// Action cells
|
||||
snapshot.appendItems([
|
||||
SwitchesViewController.ActionCell.allOn.rawValue,
|
||||
SwitchesViewController.ActionCell.allOff.rawValue
|
||||
], toSection: SwitchesViewController.actionCellsSectionIdentifier)
|
||||
|
||||
_collectionViewDataSource.apply(snapshot, animatingDifferences: false)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) { fatalError("Unimplemented") }
|
||||
|
||||
override func viewDidLoad()
|
||||
{
|
||||
super.viewDidLoad()
|
||||
@@ -69,7 +93,7 @@ class SwitchesViewController: UIViewController,
|
||||
|
||||
_collectionView.backgroundColor = UIColor.black
|
||||
_collectionView.delegate = self
|
||||
_collectionView.dataSource = self
|
||||
_collectionView.dataSource = _collectionViewDataSource
|
||||
_collectionView.register(WemoDeviceCellView.self, forCellWithReuseIdentifier: deviceCellReuseID)
|
||||
_collectionView.register(WemoActionCellView.self, forCellWithReuseIdentifier: actionCellReuseID)
|
||||
self.view.addSubview(_collectionView)
|
||||
@@ -88,96 +112,60 @@ class SwitchesViewController: UIViewController,
|
||||
|
||||
// MARK: API
|
||||
|
||||
var devices: [WemoDevice] = []
|
||||
var devices: [AnyDevice] = []
|
||||
{
|
||||
didSet
|
||||
{
|
||||
// sort devices by name
|
||||
self.devices.sort(by: { (d1: WemoDevice, d2: WemoDevice) -> Bool in
|
||||
self.devices.sort(by: { (d1: Device, d2: Device) -> Bool in
|
||||
return (d1.name.compare(d2.name, options: .caseInsensitive, range: nil, locale: nil) == .orderedAscending)
|
||||
})
|
||||
|
||||
let hash = self.devices.reduce(0, {$0 ^ $1.hashValue})
|
||||
if (hash != _currentDevicesHash) {
|
||||
let previousSet = NSOrderedSet(array: oldValue)
|
||||
let newSet = NSOrderedSet(array: self.devices)
|
||||
var insertedIndexPaths: [IndexPath] = []
|
||||
var updatedIndexPaths: [IndexPath] = []
|
||||
var deletedIndexPaths: [IndexPath] = []
|
||||
|
||||
// if we have devices now and we didn't before, or vice versa,
|
||||
// we need to update the action cells
|
||||
if ((oldValue.count == 0 && self.devices.count != 0) || (self.devices.count == 0 && oldValue.count != 0)) {
|
||||
for actionCellIdx in 0 ..< ActionCell.count {
|
||||
let actionCellIndexPath = IndexPath(item: actionCellIdx, section: 0)
|
||||
updatedIndexPaths.append(actionCellIndexPath)
|
||||
}
|
||||
}
|
||||
|
||||
// find deletes and updates
|
||||
for (idx, device) in previousSet.enumerated() {
|
||||
let itemIndex = idx + ActionCell.count
|
||||
let curIndexPath = IndexPath(item: itemIndex, section: 0)
|
||||
|
||||
if (!newSet.contains(device)) {
|
||||
deletedIndexPaths.append(curIndexPath)
|
||||
} else if (idx < newSet.count) {
|
||||
let deviceInNewSet = newSet.object(at: idx) as! WemoDevice
|
||||
if (deviceInNewSet != (device as! WemoDevice)) {
|
||||
updatedIndexPaths.append(curIndexPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// find insertions
|
||||
for (idx, device) in newSet.enumerated() {
|
||||
if (!previousSet.contains(device)) {
|
||||
let itemIndex = idx + ActionCell.count
|
||||
let insertedIndexPath = IndexPath(item: itemIndex, section: 0)
|
||||
insertedIndexPaths.append(insertedIndexPath)
|
||||
}
|
||||
}
|
||||
|
||||
UIView.performWithoutAnimation { () -> Void in
|
||||
self._collectionView.performBatchUpdates({ () -> Void in
|
||||
self._collectionView.deleteItems(at: deletedIndexPaths)
|
||||
self._collectionView.reloadItems(at: updatedIndexPaths)
|
||||
self._collectionView.insertItems(at: insertedIndexPaths)
|
||||
}, completion: nil)
|
||||
}
|
||||
|
||||
_currentDevicesHash = hash
|
||||
var snapshot = _collectionViewDataSource.snapshot()
|
||||
snapshot.deleteItems(snapshot.itemIdentifiers(inSection: SwitchesViewController.switchCellsSectionIdentifier))
|
||||
snapshot.appendItems(self.devices.map { $0.hashValue }, toSection: SwitchesViewController.switchCellsSectionIdentifier)
|
||||
_collectionViewDataSource.apply(snapshot, animatingDifferences: false)
|
||||
}
|
||||
}
|
||||
|
||||
public func devicesStateChanged(_ changedDevices: [AnyDevice])
|
||||
{
|
||||
var snapshot = _collectionViewDataSource.snapshot()
|
||||
changedDevices.forEach { changedDevice in
|
||||
if let existingDevice = (self.devices.first { $0.hashValue == changedDevice.hashValue }) {
|
||||
existingDevice.state = changedDevice.state
|
||||
snapshot.reloadItems([ existingDevice.hashValue ])
|
||||
}
|
||||
}
|
||||
|
||||
_collectionViewDataSource.apply(snapshot, animatingDifferences: true)
|
||||
}
|
||||
|
||||
// MARK: UICollectionView
|
||||
|
||||
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int
|
||||
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath, identifier: Int) -> UICollectionViewCell?
|
||||
{
|
||||
return self.devices.count + ActionCell.count
|
||||
}
|
||||
|
||||
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell
|
||||
{
|
||||
if (indexPath.item < ActionCell.count) {
|
||||
if (indexPath.section == SwitchesViewController.actionCellsSectionIdentifier) {
|
||||
let reuseID = SwitchesViewController.collectionViewActionCellReuseIdentifier
|
||||
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: reuseID, for: indexPath) as! WemoActionCellView
|
||||
cell.textLabel.text = ActionCell(rawValue: indexPath.item)?.name().uppercased()
|
||||
cell.textLabel.text = ActionCell(rawValue: identifier)?.name().uppercased()
|
||||
cell.enabled = (self.devices.count > 0)
|
||||
|
||||
return cell
|
||||
} else {
|
||||
} else if (indexPath.section == SwitchesViewController.switchCellsSectionIdentifier) {
|
||||
let reuseID = SwitchesViewController.collectionViewDeviceSwitchCellReuseIdentifier
|
||||
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: reuseID, for: indexPath) as! WemoDeviceCellView
|
||||
|
||||
let device = _deviceAtIndexPath(indexPath)
|
||||
cell.deviceName = device.name
|
||||
cell.toggled = (device.state == .on)
|
||||
cell.ordinal = indexPath.item - ActionCell.count + 1
|
||||
if let device = (self.devices.first { $0.hashValue == identifier }) {
|
||||
cell.deviceName = device.name
|
||||
cell.toggled = (device.state == .on)
|
||||
cell.ordinal = indexPath.row
|
||||
}
|
||||
|
||||
return cell
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func collectionView(_ collectionView: UICollectionView,
|
||||
@@ -206,28 +194,33 @@ class SwitchesViewController: UIViewController,
|
||||
}
|
||||
|
||||
let dimensions = floor((collectionView.bounds.size.width / cellsPerRow) - ((spacing * (cellsPerRow - 1.0)) / cellsPerRow))
|
||||
if (indexPath.item < ActionCell.count) {
|
||||
if (indexPath.section == SwitchesViewController.actionCellsSectionIdentifier) {
|
||||
return CGSize(width: collectionView.bounds.size.width, height: rint(dimensions / 1.5))
|
||||
} else {
|
||||
return CGSize(width: dimensions, height: dimensions)
|
||||
}
|
||||
}
|
||||
|
||||
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, insetForSectionAt section: Int) -> UIEdgeInsets
|
||||
{
|
||||
let spacing = SwitchesViewController.collectionViewCellsSpacing
|
||||
return UIEdgeInsets(top: spacing, left: 0, bottom: 0, right: 0)
|
||||
}
|
||||
|
||||
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath)
|
||||
{
|
||||
if (indexPath.item < ActionCell.count) {
|
||||
if (indexPath.section == SwitchesViewController.actionCellsSectionIdentifier) {
|
||||
let tappedActionCell = ActionCell(rawValue: indexPath.item)
|
||||
var currentDelay: TimeInterval = 0.0
|
||||
|
||||
for i in ActionCell.count ..< collectionView.numberOfItems(inSection: indexPath.section) {
|
||||
if let cell = collectionView.cellForItem(at: IndexPath(item: i, section: indexPath.section)) as? WemoDeviceCellView {
|
||||
for cell in collectionView.visibleCells {
|
||||
if collectionView.indexPath(for: cell)?.section == SwitchesViewController.switchCellsSectionIdentifier {
|
||||
let switchCell = cell as! WemoDeviceCellView
|
||||
let animOptions = UIView.AnimationOptions([.allowUserInteraction])
|
||||
|
||||
UIView.animate(withDuration: 0.3, delay: currentDelay, options: animOptions, animations: {
|
||||
cell.toggled = (tappedActionCell == .allOn)
|
||||
let delay: TimeInterval = Double(switchCell.ordinal) * 0.05
|
||||
UIView.animate(withDuration: 0.3, delay: delay, options: animOptions, animations: {
|
||||
switchCell.toggled = (tappedActionCell == .allOn)
|
||||
}, completion: nil)
|
||||
|
||||
currentDelay += 0.05
|
||||
}
|
||||
}
|
||||
|
||||
@@ -236,11 +229,11 @@ class SwitchesViewController: UIViewController,
|
||||
}
|
||||
|
||||
self.delegate?.switchesViewControllerDidToggleDevices(self, devices: self.devices)
|
||||
} else {
|
||||
} else if (indexPath.section == SwitchesViewController.switchCellsSectionIdentifier) {
|
||||
let cell = collectionView.cellForItem(at: indexPath) as! WemoDeviceCellView
|
||||
cell.toggled = !cell.toggled
|
||||
|
||||
let device = _deviceAtIndexPath(indexPath)
|
||||
let device = self.devices[indexPath.row]
|
||||
device.state = (cell.toggled ? .on : .off)
|
||||
|
||||
self.delegate?.switchesViewControllerDidToggleDevices(self, devices: [device])
|
||||
@@ -249,28 +242,15 @@ class SwitchesViewController: UIViewController,
|
||||
|
||||
func collectionView(_ collectionView: UICollectionView, shouldHighlightItemAt indexPath: IndexPath) -> Bool
|
||||
{
|
||||
if (indexPath.item < ActionCell.count) {
|
||||
return (self.devices.count > 0)
|
||||
} else {
|
||||
return true
|
||||
}
|
||||
return self.collectionView(collectionView, shouldSelectItemAt: indexPath)
|
||||
}
|
||||
|
||||
func collectionView(_ collectionView: UICollectionView, shouldSelectItemAt indexPath: IndexPath) -> Bool
|
||||
{
|
||||
if (indexPath.item < ActionCell.count) {
|
||||
if (indexPath.section == SwitchesViewController.actionCellsSectionIdentifier) {
|
||||
return (self.devices.count > 0)
|
||||
} else {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Internal
|
||||
|
||||
internal func _deviceAtIndexPath(_ indexPath: IndexPath) -> WemoDevice
|
||||
{
|
||||
let deviceIdx = indexPath.item - ActionCell.count
|
||||
let device = self.devices[deviceIdx]
|
||||
return device
|
||||
}
|
||||
}
|
||||
|
||||
72
XIONControlPanel/Models/DeviceProtocol.swift
Normal file
72
XIONControlPanel/Models/DeviceProtocol.swift
Normal file
@@ -0,0 +1,72 @@
|
||||
//
|
||||
// DeviceProtocol.swift
|
||||
// XIONControlPanel
|
||||
//
|
||||
// Created by James Magahern on 3/13/20.
|
||||
// Copyright © 2020 XION. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
enum DeviceState : String, Codable
|
||||
{
|
||||
case off
|
||||
case on
|
||||
}
|
||||
|
||||
enum DeviceType : String, Codable
|
||||
{
|
||||
case `switch`
|
||||
}
|
||||
|
||||
protocol Device
|
||||
{
|
||||
// Read-only properties
|
||||
var name: String { get }
|
||||
var serial: String { get }
|
||||
var type: DeviceType { get }
|
||||
|
||||
// Writable properties
|
||||
var state: DeviceState { get set }
|
||||
}
|
||||
|
||||
extension Device where Self: Hashable
|
||||
{
|
||||
func hash(into hasher: inout Hasher)
|
||||
{
|
||||
hasher.combine(self.name)
|
||||
hasher.combine(self.type)
|
||||
hasher.combine(self.serial)
|
||||
}
|
||||
|
||||
static func == (lhs: Self, rhs: Self) -> Bool
|
||||
{
|
||||
return lhs.name == rhs.name && lhs.serial == rhs.serial
|
||||
}
|
||||
}
|
||||
|
||||
class AnyDevice : Device
|
||||
{
|
||||
private var device: Device
|
||||
|
||||
public var name: String
|
||||
{ return device.name }
|
||||
|
||||
public var serial: String
|
||||
{ return device.serial }
|
||||
|
||||
public var type: DeviceType
|
||||
{ return device.type }
|
||||
|
||||
public var state: DeviceState {
|
||||
get { return device.state }
|
||||
set { device.state = newValue }
|
||||
}
|
||||
|
||||
init(_ device: Device)
|
||||
{
|
||||
self.device = device
|
||||
}
|
||||
}
|
||||
|
||||
extension AnyDevice : Hashable {}
|
||||
49
XIONControlPanel/Models/HubitatDevice.swift
Normal file
49
XIONControlPanel/Models/HubitatDevice.swift
Normal file
@@ -0,0 +1,49 @@
|
||||
//
|
||||
// HubitatDevice.swift
|
||||
// XIONControlPanel
|
||||
//
|
||||
// Created by James Magahern on 3/14/20.
|
||||
// Copyright © 2020 XION. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
public struct HubitatDevice : Device
|
||||
{
|
||||
var name: String = ""
|
||||
var serial: String = ""
|
||||
var type: DeviceType = .switch
|
||||
var state: DeviceState = .off
|
||||
|
||||
var id: Int = -1
|
||||
|
||||
enum CodingKeys: String, CodingKey
|
||||
{
|
||||
case name = "label"
|
||||
case id = "id"
|
||||
case attributes = "attributes"
|
||||
}
|
||||
|
||||
enum AttributesKeys: String, CodingKey
|
||||
{
|
||||
case power
|
||||
case dataType
|
||||
case `switch`
|
||||
}
|
||||
}
|
||||
|
||||
extension HubitatDevice : Decodable
|
||||
{
|
||||
public init(from decoder: Decoder) throws {
|
||||
let values = try decoder.container(keyedBy: CodingKeys.self)
|
||||
|
||||
let idStr = try values.decode(String.self, forKey: .id)
|
||||
id = Int(idStr)!
|
||||
serial = String(format: "hubitat:%d", id)
|
||||
|
||||
name = try values.decode(String.self, forKey: .name)
|
||||
|
||||
let attributes = try values.nestedContainer(keyedBy: Self.AttributesKeys, forKey: .attributes)
|
||||
state = try attributes.decode(DeviceState.self, forKey: .switch)
|
||||
}
|
||||
}
|
||||
@@ -8,23 +8,12 @@
|
||||
|
||||
import Foundation
|
||||
|
||||
class WemoDevice: Hashable
|
||||
class WemoDevice : Device
|
||||
{
|
||||
enum State
|
||||
{
|
||||
case off
|
||||
case on
|
||||
}
|
||||
|
||||
enum DeviceType
|
||||
{
|
||||
case `switch`
|
||||
}
|
||||
|
||||
var name: String = ""
|
||||
var host: String = ""
|
||||
var model: String = ""
|
||||
var state: State = .off
|
||||
var state: DeviceState = .off
|
||||
var type: DeviceType = .switch
|
||||
var serial: String = ""
|
||||
|
||||
|
||||
107
XIONControlPanel/Servers/HubitatServer.swift
Normal file
107
XIONControlPanel/Servers/HubitatServer.swift
Normal file
@@ -0,0 +1,107 @@
|
||||
//
|
||||
// HubitatServer.swift
|
||||
// XIONControlPanel
|
||||
//
|
||||
// Created by James Magahern on 3/14/20.
|
||||
// Copyright © 2020 XION. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Combine
|
||||
|
||||
public enum HubitatServerError : Error
|
||||
{
|
||||
case apiError
|
||||
}
|
||||
|
||||
public class HubitatServer : Server
|
||||
{
|
||||
var connected: Bool = false
|
||||
|
||||
public fileprivate(set) var devices: [HubitatDevice] = []
|
||||
|
||||
private let baseURL: URL
|
||||
private let accessToken = "fa861583-71f0-466f-8add-f29b2278af6a"
|
||||
private var fetchCancellable: AnyCancellable?
|
||||
private var switchCancellable: AnyCancellable?
|
||||
|
||||
required init(_ url: URL)
|
||||
{
|
||||
self.baseURL = url
|
||||
}
|
||||
|
||||
func connect(_ completion: @escaping (Error?) -> Void)
|
||||
{
|
||||
// RESTful, assume we're connected unless we get an error
|
||||
self.connected = true
|
||||
completion(nil)
|
||||
}
|
||||
|
||||
func disconnect(_ completion: (Error?) -> Void)
|
||||
{
|
||||
self.connected = false
|
||||
}
|
||||
|
||||
func fetchDevices(_ fetchCompletion: @escaping (Result<[AnyDevice], Error>) -> Void)
|
||||
{
|
||||
let url = baseURL.appendingPathComponent("devices/all").authenticatedURLWithAccessToken(accessToken)
|
||||
|
||||
self.fetchCancellable = URLSession.shared.dataTaskPublisher(for: url)
|
||||
.mapError { $0 as Error }
|
||||
.map { $0.data }
|
||||
.decode(type: [HubitatDevice].self, decoder: JSONDecoder())
|
||||
.sink(receiveCompletion: { completion in
|
||||
if case let Subscribers.Completion.failure(error) = completion {
|
||||
self.connected = false
|
||||
fetchCompletion(.failure(error))
|
||||
} else {
|
||||
self.connected = true
|
||||
}
|
||||
}) { (devices: [HubitatDevice]) in
|
||||
self.devices = devices
|
||||
fetchCompletion(.success(devices.map { AnyDevice($0) }))
|
||||
}
|
||||
}
|
||||
|
||||
func toggleDevice(_ device: AnyDevice, state: DeviceState, completion toggleCompletion: @escaping (Error?) -> Void)
|
||||
{
|
||||
if let hubitatDevice = (self.devices.first { $0.serial == device.serial }) {
|
||||
let command = "devices/\(hubitatDevice.id)/\(state)"
|
||||
let url = baseURL.appendingPathComponent(command).authenticatedURLWithAccessToken(accessToken)
|
||||
|
||||
self.switchCancellable = URLSession.shared.dataTaskPublisher(for: url)
|
||||
.tryMap { data, response -> Data in
|
||||
guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else {
|
||||
throw HubitatServerError.apiError
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
.decode(type: HubitatDevice.self, decoder: JSONDecoder())
|
||||
.sink(receiveCompletion: { (completion: Subscribers.Completion<Error>) in
|
||||
if case let Subscribers.Completion.failure(error) = completion {
|
||||
toggleCompletion(error)
|
||||
}
|
||||
}, receiveValue: { (device: HubitatDevice) in
|
||||
// don't need to do anything with this, but the server returns it to us.
|
||||
toggleCompletion(nil)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func responsibleForDevice(_ device: AnyDevice) -> Bool
|
||||
{
|
||||
return self.devices.contains { $0.serial == device.serial }
|
||||
}
|
||||
}
|
||||
|
||||
extension URL
|
||||
{
|
||||
func authenticatedURLWithAccessToken(_ token: String) -> URL
|
||||
{
|
||||
guard var components = URLComponents(url: self, resolvingAgainstBaseURL: false) else { fatalError() }
|
||||
components.queryItems = [ URLQueryItem(name: "access_token", value: token) ]
|
||||
|
||||
return components.url!
|
||||
}
|
||||
}
|
||||
110
XIONControlPanel/Servers/ServerMultiplex.swift
Normal file
110
XIONControlPanel/Servers/ServerMultiplex.swift
Normal file
@@ -0,0 +1,110 @@
|
||||
//
|
||||
// ServerMultiplex.swift
|
||||
// XIONControlPanel
|
||||
//
|
||||
// Created by James Magahern on 3/14/20.
|
||||
// Copyright © 2020 XION. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
protocol ServerMultiplexDelegate
|
||||
{
|
||||
func serverMultiplex(_ multiplex: ServerMultiplex, didAddDevices devices: [AnyDevice])
|
||||
func serverMultiplex(_ multiplex: ServerMultiplex, devicesStateChanged devices: [AnyDevice])
|
||||
func serverMultiplex(_ multiplex: ServerMultiplex, didEncounterError error: Error)
|
||||
}
|
||||
|
||||
enum ServerMultiplexError : Error
|
||||
{
|
||||
case unknownDevice
|
||||
}
|
||||
|
||||
class ServerMultiplex
|
||||
{
|
||||
public var delegate: ServerMultiplexDelegate?
|
||||
public private(set) var devices = Set<AnyDevice>()
|
||||
|
||||
private var servers: [Server] = []
|
||||
public var numServers: Int { return servers.count }
|
||||
|
||||
public func addServer(_ server: Server)
|
||||
{
|
||||
servers.append(server)
|
||||
}
|
||||
|
||||
public func numberOfConnectedServers() -> Int
|
||||
{
|
||||
return servers.filter { $0.connected == true }.count
|
||||
}
|
||||
|
||||
public func toggleDeviceState(_ device: AnyDevice, state: DeviceState, completion: @escaping (Error?) -> Void)
|
||||
{
|
||||
if let server = (servers.first { $0.responsibleForDevice(device) }) {
|
||||
server.toggleDevice(device, state: state, completion: completion)
|
||||
|
||||
// Update internal state
|
||||
if let index = devices.firstIndex(of: device) {
|
||||
devices[index].state = state
|
||||
}
|
||||
} else {
|
||||
completion(ServerMultiplexError.unknownDevice)
|
||||
}
|
||||
}
|
||||
|
||||
public func refreshDevices()
|
||||
{
|
||||
self.servers.forEach { (server: Server) in
|
||||
server.fetchDevices { (result: Result<[AnyDevice], Error>) in
|
||||
self.handleServerFetchResult(result)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension ServerMultiplex
|
||||
{
|
||||
private func handleServerFetchResult(_ result: Result<[AnyDevice], Error>)
|
||||
{
|
||||
switch result {
|
||||
case .success(let devices):
|
||||
handleDevicesChanged(devices)
|
||||
case .failure(let error):
|
||||
handleError(error)
|
||||
}
|
||||
}
|
||||
|
||||
private func handleDevicesChanged(_ devicesChanged: [AnyDevice])
|
||||
{
|
||||
let newDevicesSet = Set<AnyDevice>(devicesChanged)
|
||||
let additions = newDevicesSet.subtracting(self.devices)
|
||||
let changed = newDevicesSet.filter { (device: AnyDevice) in
|
||||
if let existing = (devices.first { $0.hashValue == device.hashValue }) {
|
||||
return existing.state != device.state
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
self.devices = self.devices.union(newDevicesSet)
|
||||
|
||||
if additions.count > 0 {
|
||||
DispatchQueue.main.async {
|
||||
self.delegate?.serverMultiplex(self, didAddDevices: Array(additions))
|
||||
}
|
||||
}
|
||||
|
||||
if changed.count > 0 {
|
||||
DispatchQueue.main.async {
|
||||
self.delegate?.serverMultiplex(self, devicesStateChanged: Array(changed))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handleError(_ error: Error)
|
||||
{
|
||||
DispatchQueue.main.async {
|
||||
self.delegate?.serverMultiplex(self, didEncounterError: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
33
XIONControlPanel/Servers/ServerProtocol.swift
Normal file
33
XIONControlPanel/Servers/ServerProtocol.swift
Normal file
@@ -0,0 +1,33 @@
|
||||
//
|
||||
// ServerProtocol.swift
|
||||
// XIONControlPanel
|
||||
//
|
||||
// Created by James Magahern on 3/13/20.
|
||||
// Copyright © 2020 XION. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
protocol Server
|
||||
{
|
||||
var connected: Bool { get }
|
||||
|
||||
/// Designated initializer. Takes an API endpoint URL
|
||||
init(_ url: URL)
|
||||
|
||||
/// Attempts to connect to the server at the specified endpoint.
|
||||
func connect(_ completion: @escaping (Error?) -> Void)
|
||||
|
||||
/// Sets server's state as "disconnected"
|
||||
func disconnect(_ completion: (Error?) -> Void)
|
||||
|
||||
/// Fetches a list of all currently configured devices for this server, or returns an error.
|
||||
func fetchDevices(_ completion: @escaping (Result<[AnyDevice], Error>) -> Void)
|
||||
|
||||
/// Tells the server to set the state of a given device to the given state
|
||||
func toggleDevice(_ device: AnyDevice, state: DeviceState, completion: @escaping (Error?) -> Void)
|
||||
|
||||
/// Returns true if this is a device this server is responsible for
|
||||
func responsibleForDevice(_ device: AnyDevice) -> Bool
|
||||
}
|
||||
|
||||
@@ -22,16 +22,20 @@ enum ConnectionError : Error
|
||||
case serverUnavailable
|
||||
}
|
||||
|
||||
class WemoServer
|
||||
class WemoServer : Server
|
||||
{
|
||||
private var devices: [WemoDevice] = []
|
||||
|
||||
public var connected: Bool { get { return self.connectionStatus == .connected } }
|
||||
|
||||
fileprivate(set) var baseURL: URL
|
||||
fileprivate(set) var connected: Bool = false
|
||||
fileprivate(set) var connectionStatus: ConnectionStatus = .disconnected
|
||||
|
||||
fileprivate var _urlSession: URLSession
|
||||
fileprivate var _errorStream: StandardErrorOutputStream = StandardErrorOutputStream()
|
||||
fileprivate var _operationQueue: OperationQueue = OperationQueue()
|
||||
|
||||
init(_ url: URL)
|
||||
required init(_ url: URL)
|
||||
{
|
||||
self.baseURL = url
|
||||
|
||||
@@ -43,15 +47,16 @@ class WemoServer
|
||||
|
||||
func connect(_ completion: @escaping (Error?) -> Void)
|
||||
{
|
||||
if (!self.connected) {
|
||||
if (self.connectionStatus == .disconnected) {
|
||||
let op = ConnectOperation(baseURL: self.baseURL, session: _urlSession)
|
||||
weak var weakOp = op
|
||||
op.completionBlock = {
|
||||
guard let strongOp = weakOp else { completion(nil) ; return }
|
||||
if let error = strongOp.error {
|
||||
self._logError("Error connecting to server", error: error)
|
||||
self.connectionStatus = .disconnected
|
||||
} else {
|
||||
self.connected = true
|
||||
self.connectionStatus = .connected
|
||||
}
|
||||
|
||||
completion(strongOp.error)
|
||||
@@ -64,33 +69,41 @@ class WemoServer
|
||||
|
||||
func disconnect(_ completion: (Error?) -> Void)
|
||||
{
|
||||
self.connected = false
|
||||
self.connectionStatus = .disconnected
|
||||
completion(nil)
|
||||
}
|
||||
|
||||
func fetchDevices(_ completion: @escaping ([WemoDevice], Error?) -> Void)
|
||||
func fetchDevices(_ completion: @escaping (Result<[AnyDevice], Error>) -> Void)
|
||||
{
|
||||
if (self.connected) {
|
||||
let op = FetchDevicesOperation(baseURL: self.baseURL, session: _urlSession)
|
||||
weak var weakOp = op
|
||||
op.completionBlock = {
|
||||
guard let strongOp = weakOp else { completion([], nil) ; return }
|
||||
if let error = strongOp.error {
|
||||
self._logError("Error fetching devices", error: error)
|
||||
}
|
||||
|
||||
completion(strongOp.devices, strongOp.error)
|
||||
let op = FetchDevicesOperation(baseURL: self.baseURL, session: _urlSession)
|
||||
op.completionBlock = { [unowned op] in
|
||||
if let error = op.error {
|
||||
self._logError("Error fetching devices", error: error)
|
||||
completion(.failure(error))
|
||||
} else {
|
||||
self.devices = op.devices
|
||||
}
|
||||
|
||||
self.devices = op.devices
|
||||
completion(.success(self.devices.map { AnyDevice($0) }))
|
||||
}
|
||||
|
||||
if self.connectionStatus == .disconnected {
|
||||
connect { error in
|
||||
if error == nil {
|
||||
self._operationQueue.addOperation(op)
|
||||
}
|
||||
}
|
||||
} else if self.connectionStatus == .connected {
|
||||
_operationQueue.addOperation(op)
|
||||
} else {
|
||||
let err = ConnectionError.serverUnavailable
|
||||
completion([], err)
|
||||
completion(.failure(ConnectionError.serverUnavailable))
|
||||
}
|
||||
}
|
||||
|
||||
func toggleDevice(_ device: WemoDevice, state: WemoDevice.State, completion: @escaping (Error?) -> Void)
|
||||
func toggleDevice(_ device: AnyDevice, state: DeviceState, completion: @escaping (Error?) -> Void)
|
||||
{
|
||||
if (self.connected) {
|
||||
if self.connected, let device = findDevice(device) {
|
||||
let op = ToggleDeviceOperation(baseURL: self.baseURL, session: _urlSession, device: device, state: state)
|
||||
weak var weakOp = op
|
||||
op.completionBlock = {
|
||||
@@ -108,12 +121,22 @@ class WemoServer
|
||||
}
|
||||
}
|
||||
|
||||
func responsibleForDevice(_ device: AnyDevice) -> Bool
|
||||
{
|
||||
return self.devices.contains { $0.serial == device.serial }
|
||||
}
|
||||
|
||||
// MARK: Internal
|
||||
|
||||
internal func _logError(_ description: String, error: Error)
|
||||
{
|
||||
print("ERROR: \(description) \(error)", to: &_errorStream)
|
||||
}
|
||||
|
||||
internal func findDevice(_ device: AnyDevice) -> WemoDevice?
|
||||
{
|
||||
return self.devices.first { $0.serial == device.serial }
|
||||
}
|
||||
}
|
||||
|
||||
internal class WemoOperation : Operation
|
||||
@@ -188,9 +211,9 @@ internal class FetchDevicesOperation : WemoOperation
|
||||
internal class ToggleDeviceOperation : WemoOperation
|
||||
{
|
||||
var device: WemoDevice
|
||||
var state: WemoDevice.State
|
||||
var state: DeviceState
|
||||
|
||||
init(baseURL: URL, session: URLSession, device: WemoDevice, state: WemoDevice.State)
|
||||
init(baseURL: URL, session: URLSession, device: WemoDevice, state: DeviceState)
|
||||
{
|
||||
self.device = device
|
||||
self.state = state
|
||||
@@ -15,11 +15,11 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0</string>
|
||||
<string>$(MARKETING_VERSION)</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
|
||||
Reference in New Issue
Block a user