From 918818cd99fb93b3c8f58406f591340d37b4ba41 Mon Sep 17 00:00:00 2001 From: James Magahern Date: Sat, 14 Mar 2020 18:33:02 -0700 Subject: [PATCH] ServerMultiplex: Introduces a server mutiplexer - Also moves all the existing Wemo* stuff over to support the multiplexer - Moves MainViewController to be the owner of the multiplexer, and its delegate --- XIONControlPanel.xcodeproj/project.pbxproj | 22 ++- .../Controllers/MainViewController.swift | 89 +++++------- .../Controllers/SwitchesViewController.swift | 24 ++-- XIONControlPanel/Models/DeviceProtocol.swift | 71 +++++++++ XIONControlPanel/Models/WemoDevice.swift | 15 +- .../Servers/ServerMultiplex.swift | 136 ++++++++++++++++++ XIONControlPanel/Servers/ServerProtocol.swift | 24 ++++ XIONControlPanel/Servers/WemoServer.swift | 37 +++-- 8 files changed, 335 insertions(+), 83 deletions(-) create mode 100644 XIONControlPanel/Models/DeviceProtocol.swift create mode 100644 XIONControlPanel/Servers/ServerMultiplex.swift diff --git a/XIONControlPanel.xcodeproj/project.pbxproj b/XIONControlPanel.xcodeproj/project.pbxproj index 5bb7d3f..a2dc1ec 100644 --- a/XIONControlPanel.xcodeproj/project.pbxproj +++ b/XIONControlPanel.xcodeproj/project.pbxproj @@ -36,6 +36,9 @@ 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 */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -73,6 +76,9 @@ 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 = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -112,7 +118,6 @@ 0C20E2BA1C33529700BA789B /* MainViewController.swift */, 0C41EF491C34B51500AF847F /* SwitchesViewController.swift */, 0C20E2BC1C33534F00BA789B /* VisualizationViewController.swift */, - 0CAA32781C361A2400B353CF /* WemoServer.swift */, ); path = Controllers; sourceTree = ""; @@ -120,6 +125,7 @@ 0C20E2B71C33514100BA789B /* Models */ = { isa = PBXGroup; children = ( + CD815B8F241C7CAC00E2FA97 /* DeviceProtocol.swift */, 0C4314FF1C35B1E700164559 /* WemoDevice.swift */, ); path = Models; @@ -169,6 +175,7 @@ 0C20E2B61C33514100BA789B /* Controllers */, 0C41EF4B1C34B81F00AF847F /* External */, 0C20E2B71C33514100BA789B /* Models */, + CD815B8C241C6F2C00E2FA97 /* Servers */, 0C63A8741C3373BC007E4B52 /* Shaders */, 0C20E2C81C336F2E00BA789B /* Resources */, 0C63A8791C33C622007E4B52 /* Utilities */, @@ -215,6 +222,16 @@ path = Utilities; sourceTree = ""; }; + CD815B8C241C6F2C00E2FA97 /* Servers */ = { + isa = PBXGroup; + children = ( + CD815B91241DA28B00E2FA97 /* ServerMultiplex.swift */, + CD815B8D241C6F4000E2FA97 /* ServerProtocol.swift */, + 0CAA32781C361A2400B353CF /* WemoServer.swift */, + ); + path = Servers; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -305,6 +322,7 @@ 0C41EF561C35ABCD00AF847F /* ConnectionStatusView.swift in Sources */, 0C63A87D1C33C655007E4B52 /* NSValue+XIONAdditions.m 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 +330,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 */, diff --git a/XIONControlPanel/Controllers/MainViewController.swift b/XIONControlPanel/Controllers/MainViewController.swift index 54a81e7..4c07bbb 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,10 @@ 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(WemoServer(URL(string: "http://midna.xionsf.com:5000")!)) } required init?(coder aDecoder: NSCoder) @@ -104,16 +104,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 +129,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 +163,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 +181,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 +188,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 +198,28 @@ class MainViewController: UIViewController, SwitchesViewControllerDelegate { _updateDevices = false } + + // MARK: Server Multiplex Delegate + + func serverMultiplex(_ multiplex: ServerMultiplex, didAddDevices devices: [AnyDevice]) + { + _switchesController.devices = 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) + print(error.localizedDescription) + } } diff --git a/XIONControlPanel/Controllers/SwitchesViewController.swift b/XIONControlPanel/Controllers/SwitchesViewController.swift index 663205a..61d5e0b 100644 --- a/XIONControlPanel/Controllers/SwitchesViewController.swift +++ b/XIONControlPanel/Controllers/SwitchesViewController.swift @@ -12,12 +12,7 @@ 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, @@ -117,12 +112,12 @@ 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) }) @@ -132,6 +127,19 @@ class SwitchesViewController: UIViewController, } } + 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, cellForItemAt indexPath: IndexPath, identifier: Int) -> UICollectionViewCell? diff --git a/XIONControlPanel/Models/DeviceProtocol.swift b/XIONControlPanel/Models/DeviceProtocol.swift new file mode 100644 index 0000000..515d655 --- /dev/null +++ b/XIONControlPanel/Models/DeviceProtocol.swift @@ -0,0 +1,71 @@ +// +// DeviceProtocol.swift +// XIONControlPanel +// +// Created by James Magahern on 3/13/20. +// Copyright © 2020 XION. All rights reserved. +// + +import Foundation + +enum DeviceState +{ + case off + case on +} + +enum DeviceType +{ + 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 + { get { return device.name } } + + public var serial: String + { get { return device.serial } } + + public var type: DeviceType + { get { 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/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/ServerMultiplex.swift b/XIONControlPanel/Servers/ServerMultiplex.swift new file mode 100644 index 0000000..95d9874 --- /dev/null +++ b/XIONControlPanel/Servers/ServerMultiplex.swift @@ -0,0 +1,136 @@ +// +// 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 { get { 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() + { + ensureConnection { (error: Error?) in + if let error = error { + self.handleError(error) + } else { + self.servers.forEach { (server: Server) in + server.fetchDevices { (result: Result<[AnyDevice], Error>) in + self.handleServerFetchResult(result) + } + } + } + } + } +} + +extension ServerMultiplex +{ + private func ensureConnection(then: @escaping (Error?) -> Void) + { + let disconnectedServers = servers.filter { $0.connected == false } + var disconnectedServerCount = disconnectedServers.count + + if disconnectedServerCount > 0 { + disconnectedServers.forEach { (server: Server) in + server.connect { (error: Error?) in + disconnectedServerCount -= 1 + if disconnectedServerCount == 0 { + then(error) + } + } + } + } else { + then(nil) + } + } + + 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) + if additions.count > 0 { + DispatchQueue.main.async { + self.delegate?.serverMultiplex(self, didAddDevices: Array(additions)) + } + } + + let changed = newDevicesSet.filter { (device: AnyDevice) in + if let existing = (devices.first { $0.hashValue == device.hashValue }) { + return existing.state != device.state + } + + return false + } + + if changed.count > 0 { + DispatchQueue.main.async { + self.delegate?.serverMultiplex(self, devicesStateChanged: Array(changed)) + } + } + + self.devices = self.devices.union(newDevicesSet) + } + + 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 index fed384a..c9ab118 100644 --- a/XIONControlPanel/Servers/ServerProtocol.swift +++ b/XIONControlPanel/Servers/ServerProtocol.swift @@ -7,3 +7,27 @@ // 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/Servers/WemoServer.swift b/XIONControlPanel/Servers/WemoServer.swift index 0348a0d..a364cf6 100644 --- a/XIONControlPanel/Servers/WemoServer.swift +++ b/XIONControlPanel/Servers/WemoServer.swift @@ -22,8 +22,10 @@ enum ConnectionError : Error case serverUnavailable } -class WemoServer +class WemoServer : Server { + private var devices: [WemoDevice] = [] + fileprivate(set) var baseURL: URL fileprivate(set) var connected: Bool = false @@ -31,7 +33,7 @@ class WemoServer fileprivate var _errorStream: StandardErrorOutputStream = StandardErrorOutputStream() fileprivate var _operationQueue: OperationQueue = OperationQueue() - init(_ url: URL) + required init(_ url: URL) { self.baseURL = url @@ -68,29 +70,32 @@ class WemoServer 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 } + guard let strongOp = weakOp else { return } if let error = strongOp.error { self._logError("Error fetching devices", error: error) + completion(.failure(error)) + } else { + self.devices = strongOp.devices } - completion(strongOp.devices, strongOp.error) + self.devices = strongOp.devices + completion(.success(self.devices.map { AnyDevice($0) })) } _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 +113,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 +203,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