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
This commit is contained in:
2020-03-14 18:33:02 -07:00
parent f1f86ced29
commit 918818cd99
8 changed files with 335 additions and 83 deletions

View File

@@ -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 = "<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>"; };
/* 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 = "<group>";
@@ -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 = "<group>";
};
CD815B8C241C6F2C00E2FA97 /* Servers */ = {
isa = PBXGroup;
children = (
CD815B91241DA28B00E2FA97 /* ServerMultiplex.swift */,
CD815B8D241C6F4000E2FA97 /* ServerProtocol.swift */,
0CAA32781C361A2400B353CF /* WemoServer.swift */,
);
path = Servers;
sourceTree = "<group>";
};
/* 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 */,

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

View File

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

View File

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

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,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<AnyDevice>()
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<AnyDevice>(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)
}
}
}

View File

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

View File

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