Fixes issue where connectivity issues can lead to persistent "Offline" status in UI

This fixes the problem where either internet connectivity issues on the device or intermittant
server issues can cause a persistent "OFFLINE" indicator even though that is not the case.

This requires the server protocol to be more granular about what their connection status is,
and adds a new signal from the multiplexer to the MainViewController allowing it to notify the
delegate that an acknowledgement was heard from one of the servers, allowing the main view
controller to update its UI according to whatever status best represents the group of servers
as a whole.

Testing
- This can be tested by using the network link conditioner to simulate connection issues
  *after* the control panel has connected to all servers in the multiplex.
- Tested losing internet connection and regaining internet connection
- Tested starting off with no connection and regaining connection later
This commit is contained in:
2020-07-04 19:43:50 -07:00
parent ee29602640
commit d1da677932
5 changed files with 55 additions and 31 deletions

View File

@@ -206,11 +206,6 @@ class MainViewController: UIViewController, SwitchesViewControllerDelegate, Serv
{ {
_switchesController.devices = Array(multiplex.devices) _switchesController.devices = Array(multiplex.devices)
_updateVisualization(false) _updateVisualization(false)
// Update connection status too, if applicable
if _serverMultiplex.numServers == _serverMultiplex.numberOfConnectedServers() {
_updateConnectivityStatus(.connected)
}
} }
func serverMultiplex(_ multiplex: ServerMultiplex, devicesStateChanged devices: [AnyDevice]) func serverMultiplex(_ multiplex: ServerMultiplex, devicesStateChanged devices: [AnyDevice])
@@ -218,6 +213,13 @@ class MainViewController: UIViewController, SwitchesViewControllerDelegate, Serv
_switchesController.devicesStateChanged(devices) _switchesController.devicesStateChanged(devices)
} }
func serverMultiplex(_ multiplex: ServerMultiplex, didReceiveAcknowledgementFromServer server: Server)
{
// Update connection status too, if applicable
let groupConnectionStatus = multiplex.groupConnectionStatus()
_updateConnectivityStatus(groupConnectionStatus)
}
func serverMultiplex(_ multiplex: ServerMultiplex, didEncounterError error: Error) func serverMultiplex(_ multiplex: ServerMultiplex, didEncounterError error: Error)
{ {
_updateConnectivityStatus(.error) _updateConnectivityStatus(.error)

View File

@@ -16,7 +16,7 @@ public enum HubitatServerError : Error
public class HubitatServer : Server public class HubitatServer : Server
{ {
var connected: Bool = false var connectionStatus: ConnectionStatus = .disconnected
public fileprivate(set) var devices: [HubitatDevice] = [] public fileprivate(set) var devices: [HubitatDevice] = []
@@ -33,13 +33,13 @@ public class HubitatServer : Server
func connect(_ completion: @escaping (Error?) -> Void) func connect(_ completion: @escaping (Error?) -> Void)
{ {
// RESTful, assume we're connected unless we get an error // RESTful, assume we're connected unless we get an error
self.connected = true connectionStatus = .connected
completion(nil) completion(nil)
} }
func disconnect(_ completion: (Error?) -> Void) func disconnect(_ completion: (Error?) -> Void)
{ {
self.connected = false connectionStatus = .disconnected
} }
func fetchDevices(_ fetchCompletion: @escaping (Result<[AnyDevice], Error>) -> Void) func fetchDevices(_ fetchCompletion: @escaping (Result<[AnyDevice], Error>) -> Void)
@@ -52,10 +52,10 @@ public class HubitatServer : Server
.decode(type: [HubitatDevice].self, decoder: JSONDecoder()) .decode(type: [HubitatDevice].self, decoder: JSONDecoder())
.sink(receiveCompletion: { completion in .sink(receiveCompletion: { completion in
if case let Subscribers.Completion.failure(error) = completion { if case let Subscribers.Completion.failure(error) = completion {
self.connected = false self.connectionStatus = .error
fetchCompletion(.failure(error)) fetchCompletion(.failure(error))
} else { } else {
self.connected = true self.connectionStatus = .connected
} }
}) { (devices: [HubitatDevice]) in }) { (devices: [HubitatDevice]) in
self.devices = devices self.devices = devices

View File

@@ -12,6 +12,7 @@ protocol ServerMultiplexDelegate
{ {
func serverMultiplex(_ multiplex: ServerMultiplex, didAddDevices devices: [AnyDevice]) func serverMultiplex(_ multiplex: ServerMultiplex, didAddDevices devices: [AnyDevice])
func serverMultiplex(_ multiplex: ServerMultiplex, devicesStateChanged devices: [AnyDevice]) func serverMultiplex(_ multiplex: ServerMultiplex, devicesStateChanged devices: [AnyDevice])
func serverMultiplex(_ multiplex: ServerMultiplex, didReceiveAcknowledgementFromServer server: Server)
func serverMultiplex(_ multiplex: ServerMultiplex, didEncounterError error: Error) func serverMultiplex(_ multiplex: ServerMultiplex, didEncounterError error: Error)
} }
@@ -33,9 +34,26 @@ class ServerMultiplex
servers.append(server) servers.append(server)
} }
public func numberOfConnectedServers() -> Int /// Returns a status that best represents the status of the whole group, as if it were one device
public func groupConnectionStatus() -> ConnectionStatus
{ {
return servers.filter { $0.connected == true }.count let relevancyRank: (ConnectionStatus) -> Int = { status in
switch status {
case .disconnected: return 0
case .connected: return 1
case .connecting: return 2
case .error: return 3
}
}
var groupStatus = ConnectionStatus.connected
servers.forEach { server in
if relevancyRank(server.connectionStatus) > relevancyRank(groupStatus) {
groupStatus = server.connectionStatus
}
}
return groupStatus
} }
public func toggleDeviceState(_ device: AnyDevice, state: DeviceState, completion: @escaping (Error?) -> Void) public func toggleDeviceState(_ device: AnyDevice, state: DeviceState, completion: @escaping (Error?) -> Void)
@@ -56,7 +74,7 @@ class ServerMultiplex
{ {
self.servers.forEach { (server: Server) in self.servers.forEach { (server: Server) in
server.fetchDevices { (result: Result<[AnyDevice], Error>) in server.fetchDevices { (result: Result<[AnyDevice], Error>) in
self.handleServerFetchResult(result) self.handleServerFetchResult(forServer: server, result: result)
} }
} }
} }
@@ -64,18 +82,22 @@ class ServerMultiplex
extension ServerMultiplex extension ServerMultiplex
{ {
private func handleServerFetchResult(_ result: Result<[AnyDevice], Error>) private func handleServerFetchResult(forServer server: Server, result: Result<[AnyDevice], Error>)
{ {
switch result { switch result {
case .success(let devices): case .success(let devices):
handleDevicesChanged(devices) handleDevicesChanged(forServer: server, devicesChanged: devices)
case .failure(let error): case .failure(let error):
handleError(error) handleError(forServer: server, error: error)
} }
} }
private func handleDevicesChanged(_ devicesChanged: [AnyDevice]) private func handleDevicesChanged(forServer server: Server, devicesChanged: [AnyDevice])
{ {
// First send server acknowledgement
self.delegate?.serverMultiplex(self, didReceiveAcknowledgementFromServer: server)
// Then, optionally notify about new devices or device state changes
let newDevicesSet = Set<AnyDevice>(devicesChanged) let newDevicesSet = Set<AnyDevice>(devicesChanged)
let additions = newDevicesSet.subtracting(self.devices) let additions = newDevicesSet.subtracting(self.devices)
let changed = newDevicesSet.filter { (device: AnyDevice) in let changed = newDevicesSet.filter { (device: AnyDevice) in
@@ -101,7 +123,7 @@ extension ServerMultiplex
} }
} }
private func handleError(_ error: Error) private func handleError(forServer server: Server, error: Error)
{ {
DispatchQueue.main.async { DispatchQueue.main.async {
self.delegate?.serverMultiplex(self, didEncounterError: error) self.delegate?.serverMultiplex(self, didEncounterError: error)

View File

@@ -8,9 +8,17 @@
import Foundation import Foundation
enum ConnectionStatus
{
case disconnected
case connecting
case connected
case error
}
protocol Server protocol Server
{ {
var connected: Bool { get } var connectionStatus: ConnectionStatus { get }
/// Designated initializer. Takes an API endpoint URL /// Designated initializer. Takes an API endpoint URL
init(_ url: URL) init(_ url: URL)

View File

@@ -8,14 +8,6 @@
import Foundation import Foundation
enum ConnectionStatus
{
case disconnected
case connecting
case connected
case error
}
enum ConnectionError : Error enum ConnectionError : Error
{ {
case unknown case unknown
@@ -25,11 +17,9 @@ enum ConnectionError : Error
class WemoServer : Server class WemoServer : Server
{ {
private var devices: [WemoDevice] = [] private var devices: [WemoDevice] = []
public var connectionStatus: ConnectionStatus = .disconnected
public var connected: Bool { get { return self.connectionStatus == .connected } }
fileprivate(set) var baseURL: URL fileprivate(set) var baseURL: URL
fileprivate(set) var connectionStatus: ConnectionStatus = .disconnected
fileprivate var _urlSession: URLSession fileprivate var _urlSession: URLSession
fileprivate var _errorStream: StandardErrorOutputStream = StandardErrorOutputStream() fileprivate var _errorStream: StandardErrorOutputStream = StandardErrorOutputStream()
@@ -48,6 +38,8 @@ class WemoServer : Server
func connect(_ completion: @escaping (Error?) -> Void) func connect(_ completion: @escaping (Error?) -> Void)
{ {
if (self.connectionStatus == .disconnected) { if (self.connectionStatus == .disconnected) {
self.connectionStatus = .connecting
let op = ConnectOperation(baseURL: self.baseURL, session: _urlSession) let op = ConnectOperation(baseURL: self.baseURL, session: _urlSession)
weak var weakOp = op weak var weakOp = op
op.completionBlock = { op.completionBlock = {
@@ -103,7 +95,7 @@ class WemoServer : Server
func toggleDevice(_ device: AnyDevice, state: DeviceState, completion: @escaping (Error?) -> Void) func toggleDevice(_ device: AnyDevice, state: DeviceState, completion: @escaping (Error?) -> Void)
{ {
if self.connected, let device = findDevice(device) { if connectionStatus == .connected, let device = findDevice(device) {
let op = ToggleDeviceOperation(baseURL: self.baseURL, session: _urlSession, device: device, state: state) let op = ToggleDeviceOperation(baseURL: self.baseURL, session: _urlSession, device: device, state: state)
weak var weakOp = op weak var weakOp = op
op.completionBlock = { op.completionBlock = {