diff --git a/XIONControlPanel.xcodeproj/project.pbxproj b/XIONControlPanel.xcodeproj/project.pbxproj index 9cfefad..e340dcd 100644 --- a/XIONControlPanel.xcodeproj/project.pbxproj +++ b/XIONControlPanel.xcodeproj/project.pbxproj @@ -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 = ""; }; 0CF0F8951C362D8F00FE7752 /* NSURLAdditions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NSURLAdditions.swift; sourceTree = ""; }; 0CF9A48D1C35E9C800276F13 /* SwitchIndicatorView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SwitchIndicatorView.swift; sourceTree = ""; }; + CD815B8D241C6F4000E2FA97 /* ServerProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerProtocol.swift; sourceTree = ""; }; + CD815B8F241C7CAC00E2FA97 /* DeviceProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceProtocol.swift; sourceTree = ""; }; + CD815B91241DA28B00E2FA97 /* ServerMultiplex.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerMultiplex.swift; sourceTree = ""; }; + CD815B93241DC16700E2FA97 /* HubitatServer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HubitatServer.swift; sourceTree = ""; }; + CD815B95241DC44D00E2FA97 /* HubitatDevice.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HubitatDevice.swift; sourceTree = ""; }; /* 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 = ""; @@ -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 = ""; }; + CD815B8C241C6F2C00E2FA97 /* Servers */ = { + isa = PBXGroup; + children = ( + CD815B91241DA28B00E2FA97 /* ServerMultiplex.swift */, + CD815B8D241C6F4000E2FA97 /* ServerProtocol.swift */, + CD815B93241DC16700E2FA97 /* HubitatServer.swift */, + 0CAA32781C361A2400B353CF /* WemoServer.swift */, + ); + path = Servers; + sourceTree = ""; + }; /* 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"; diff --git a/XIONControlPanel/Controllers/MainViewController.swift b/XIONControlPanel/Controllers/MainViewController.swift index 54a81e7..ea39e6d 100644 --- a/XIONControlPanel/Controllers/MainViewController.swift +++ b/XIONControlPanel/Controllers/MainViewController.swift @@ -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) + } } diff --git a/XIONControlPanel/Controllers/SwitchesViewController.swift b/XIONControlPanel/Controllers/SwitchesViewController.swift index cd787aa..b831789 100644 --- a/XIONControlPanel/Controllers/SwitchesViewController.swift +++ b/XIONControlPanel/Controllers/SwitchesViewController.swift @@ -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! + + 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 - } } diff --git a/XIONControlPanel/Models/DeviceProtocol.swift b/XIONControlPanel/Models/DeviceProtocol.swift new file mode 100644 index 0000000..15db6a6 --- /dev/null +++ b/XIONControlPanel/Models/DeviceProtocol.swift @@ -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 {} diff --git a/XIONControlPanel/Models/HubitatDevice.swift b/XIONControlPanel/Models/HubitatDevice.swift new file mode 100644 index 0000000..72461c6 --- /dev/null +++ b/XIONControlPanel/Models/HubitatDevice.swift @@ -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) + } +} diff --git a/XIONControlPanel/Models/WemoDevice.swift b/XIONControlPanel/Models/WemoDevice.swift index e8c0849..45d7b59 100644 --- a/XIONControlPanel/Models/WemoDevice.swift +++ b/XIONControlPanel/Models/WemoDevice.swift @@ -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 = "" diff --git a/XIONControlPanel/Servers/HubitatServer.swift b/XIONControlPanel/Servers/HubitatServer.swift new file mode 100644 index 0000000..5d7eb56 --- /dev/null +++ b/XIONControlPanel/Servers/HubitatServer.swift @@ -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) 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! + } +} diff --git a/XIONControlPanel/Servers/ServerMultiplex.swift b/XIONControlPanel/Servers/ServerMultiplex.swift new file mode 100644 index 0000000..cdfcfbf --- /dev/null +++ b/XIONControlPanel/Servers/ServerMultiplex.swift @@ -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() + + 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(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) + } + } +} diff --git a/XIONControlPanel/Servers/ServerProtocol.swift b/XIONControlPanel/Servers/ServerProtocol.swift new file mode 100644 index 0000000..c9ab118 --- /dev/null +++ b/XIONControlPanel/Servers/ServerProtocol.swift @@ -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 +} + diff --git a/XIONControlPanel/Controllers/WemoServer.swift b/XIONControlPanel/Servers/WemoServer.swift similarity index 74% rename from XIONControlPanel/Controllers/WemoServer.swift rename to XIONControlPanel/Servers/WemoServer.swift index 0348a0d..5e688ac 100644 --- a/XIONControlPanel/Controllers/WemoServer.swift +++ b/XIONControlPanel/Servers/WemoServer.swift @@ -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 diff --git a/XIONControlPanel/SupportingFiles/Info.plist b/XIONControlPanel/SupportingFiles/Info.plist index 6ce6140..9e4a9df 100644 --- a/XIONControlPanel/SupportingFiles/Info.plist +++ b/XIONControlPanel/SupportingFiles/Info.plist @@ -15,11 +15,11 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.0 + $(MARKETING_VERSION) CFBundleSignature ???? CFBundleVersion - 1 + $(CURRENT_PROJECT_VERSION) LSRequiresIPhoneOS NSAppTransportSecurity