Merge branch 'buzzert/hubitat' into 'master'

Hubitat Support

See merge request zanneth/XIONControlPanel!1
This commit is contained in:
zanneth
2020-05-09 03:50:53 -04:00
11 changed files with 620 additions and 203 deletions

View File

@@ -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";

View File

@@ -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)
}
}

View File

@@ -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?
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)
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)
}
}
// 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)
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 ])
}
}
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
}
}
_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)
if let device = (self.devices.first { $0.hashValue == identifier }) {
cell.deviceName = device.name
cell.toggled = (device.state == .on)
cell.ordinal = indexPath.item - ActionCell.count + 1
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
}
}

View 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 {}

View 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)
}
}

View File

@@ -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 = ""

View 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!
}
}

View 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)
}
}
}

View 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
}

View File

@@ -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 {
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
}
completion(strongOp.devices, strongOp.error)
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

View File

@@ -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>