Skip to content

Commit

Permalink
Replaces OpenHABTracker with NetworkTracker, adds observability to Pr…
Browse files Browse the repository at this point in the history
…eferences

Signed-off-by: Dan Cunningham <[email protected]>
  • Loading branch information
digitaldan committed Sep 13, 2024
1 parent 1f5a495 commit 4ea7db0
Show file tree
Hide file tree
Showing 7 changed files with 318 additions and 434 deletions.
182 changes: 182 additions & 0 deletions OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
// Copyright (c) 2010-2024 Contributors to the openHAB project
//
// See the NOTICE file(s) distributed with this work for additional
// information.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0
//
// SPDX-License-Identifier: EPL-2.0

import Alamofire
import Foundation
import Network

// TODO: these strings should reference Localizable keys
public enum NetworkStatus: String {
case notConnected = "Not Connected"
case connecting
case connected = "Connected"
case connectionFailed = "Connection Failed"
}

// Anticipating supporting more robust configuration options where we allow multiple url/user/pass options for users
public struct ConnectionObject: Equatable {
public let url: String
public let priority: Int // Lower is higher priority, 0 is primary

public init(url: String, priority: Int = 10) {
self.url = url
self.priority = priority
}

public static func == (lhs: ConnectionObject, rhs: ConnectionObject) -> Bool {
lhs.url == rhs.url && lhs.priority == rhs.priority
}
}

public final class NetworkTracker: ObservableObject {
public static let shared = NetworkTracker()
@Published public private(set) var activeServer: ConnectionObject?
@Published public private(set) var status: NetworkStatus = .notConnected
private var sseTask: URLSessionDataTask?
private var monitor: NWPathMonitor
private var monitorQueue = DispatchQueue.global(qos: .background)
private var connectionObjects: [ConnectionObject] = []

private init() {
monitor = NWPathMonitor()
monitor.pathUpdateHandler = { [weak self] path in
if path.status == .satisfied {
print("Network status: Connected")
self?.attemptConnection()
} else {
print("Network status: Disconnected")
self?.updateStatus(.notConnected)
}
}
monitor.start(queue: monitorQueue)
}

public func startTracking(connectionObjects: [ConnectionObject]) {
self.connectionObjects = connectionObjects
attemptConnection()
}

private func checkActiveServer() {
guard let activeServer else {
// No active server, proceed with the normal connection attempt
attemptConnection()
return
}
// Check if the active server is reachable by making a lightweight request (e.g., a HEAD request)
NetworkConnection.tracker(openHABRootUrl: activeServer.url) { [weak self] response in
switch response.result {
case .success:
print("Network status: Active server is reachable: \(activeServer.url)")
self?.updateStatus(.connected) // If reachable, we're done
case .failure:
print("Network status: Active server is not reachable: \(activeServer.url)")
self?.attemptConnection() // If not reachable, run the connection logic
}
}
}

private func attemptConnection() {
guard !connectionObjects.isEmpty else {
print("Network status: No connection objects available.")
updateStatus(.notConnected)
return
}

// updateStatus(.connecting)
let dispatchGroup = DispatchGroup()
var highestPriorityConnection: ConnectionObject?
var nonPriorityConnection: ConnectionObject?

// Set the time window for priority connections (e.g., 2 seconds)
// if a priority = 0 finishes before this time, we continue, otherwise we wait this long before picking a winner based on priority
let priorityWaitTime: TimeInterval = 2.0
var priorityWorkItem: DispatchWorkItem?

for connection in connectionObjects {
dispatchGroup.enter()
NetworkConnection.tracker(openHABRootUrl: connection.url) { [weak self] response in
guard let self else {
return
}
switch response.result {
case let .success(data):
let version = getServerInfoFromData(data: data)
if version > 0 {
// Handle the first connection
if connection.priority == 0, highestPriorityConnection == nil {
// This is the highest priority connection
highestPriorityConnection = connection
priorityWorkItem?.cancel() // Cancel any waiting task if the highest priority connected
setActiveServer(connection)
} else if highestPriorityConnection == nil, nonPriorityConnection == nil {
// First non-priority connection
nonPriorityConnection = connection
}
dispatchGroup.leave()
} else {
print("Network status: Failed version when connecting to: \(connection.url)")
dispatchGroup.leave()
}
case let .failure(error):
print("Network status: Failed connection to: \(connection.url) : \(error.localizedDescription)")
dispatchGroup.leave()
}
}
}

// Create a work item that waits for the priority connection
priorityWorkItem = DispatchWorkItem { [weak self] in
if let nonPriorityConnection, highestPriorityConnection == nil {
// If no priority connection succeeded, use the first non-priority one
self?.setActiveServer(nonPriorityConnection)
}
}

// Wait for the priority connection for 2 seconds
DispatchQueue.global().asyncAfter(deadline: .now() + priorityWaitTime, execute: priorityWorkItem!)

dispatchGroup.notify(queue: .main) {
if let highestPriorityConnection {
print("Network status: Highest priority connection established: \(highestPriorityConnection.url)")
} else if let nonPriorityConnection {
print("Network status: Non-priority connection established: \(nonPriorityConnection.url)")
} else {
print("Network status: No server responded.")
self.updateStatus(.connectionFailed)
}
}
}

private func setActiveServer(_ server: ConnectionObject) {
print("Network status: setActiveServer: \(server.url)")

if activeServer != server {
activeServer = server
}
updateStatus(.connected)
}

private func updateStatus(_ newStatus: NetworkStatus) {
if status != newStatus {
status = newStatus
}
}

private func getServerInfoFromData(data: Data) -> Int {
do {
let serverProperties = try data.decoded(as: OpenHABServerProperties.self)
// OH versions 2.0 through 2.4 return "1" as thier version, so set the floor to 2 so we do not think this is a OH 1.x serevr
return max(2, Int(serverProperties.version) ?? 2)
} catch {
return -1
}
}
}
72 changes: 48 additions & 24 deletions OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,64 +9,89 @@
//
// SPDX-License-Identifier: EPL-2.0

import Combine
import os.log
import UIKit

// Convenient access to UserDefaults

// Much shorter as Property Wrappers are available with Swift 5.1
// Inspired by https://www.avanderlee.com/swift/property-wrappers/
@propertyWrapper
public struct UserDefault<T> {
let key: String
let defaultValue: T
private let key: String
private let defaultValue: T
private let subject: CurrentValueSubject<T, Never>

public var wrappedValue: T {
get {
Preferences.sharedDefaults.object(forKey: key) as? T ?? defaultValue
let value = Preferences.sharedDefaults.object(forKey: key) as? T ?? defaultValue
return value
}
set {
Preferences.sharedDefaults.set(newValue, forKey: key)
let subject = subject
DispatchQueue.main.async {
subject.send(newValue)
}
}
}

init(_ key: String, defaultValue: T) {
public init(_ key: String, defaultValue: T) {
self.key = key
self.defaultValue = defaultValue
let currentValue = Preferences.sharedDefaults.object(forKey: key) as? T ?? defaultValue
subject = CurrentValueSubject<T, Never>(currentValue)
}
}

// It would be nice to write something like @UserDefault @TrimmedURL ("localUrl", defaultValue: "test") static var localUrl: String
// As long as multiple property wrappers are not supported we need to add a little repetitive boiler plate code
public var projectedValue: AnyPublisher<T, Never> {
subject.eraseToAnyPublisher()
}
}

@propertyWrapper
public struct UserDefaultURL {
let key: String
let defaultValue: String
private let key: String
private let defaultValue: String
private let subject: CurrentValueSubject<String, Never>

public var wrappedValue: String {
get {
guard let localUrl = Preferences.sharedDefaults.string(forKey: key) else { return defaultValue }
let trimmedUri = uriWithoutTrailingSlashes(localUrl).trimmingCharacters(in: CharacterSet.whitespacesAndNewlines)
if !trimmedUri.isValidURL { return defaultValue }
return trimmedUri
let storedValue = Preferences.sharedDefaults.string(forKey: key) ?? defaultValue
let trimmedUri = uriWithoutTrailingSlashes(storedValue).trimmingCharacters(in: .whitespacesAndNewlines)
return trimmedUri.isValidURL ? trimmedUri : defaultValue
}
set {
print("Setting Preferences")
Preferences.sharedDefaults.set(newValue, forKey: key)
let subject = subject
let defaultValue = defaultValue
// Trim and validate the new URL
let trimmedUri = uriWithoutTrailingSlashes(newValue).trimmingCharacters(in: .whitespacesAndNewlines)
DispatchQueue.main.async {
if trimmedUri.isValidURL {
print("Sending trimedUri change \(trimmedUri)")
subject.send(trimmedUri)
} else {
print("Sendingdefault URL change \(trimmedUri)")
subject.send(defaultValue)
}
}
}
}

init(_ key: String, defaultValue: String) {
public init(_ key: String, defaultValue: String) {
self.key = key
self.defaultValue = defaultValue
let currentValue = Preferences.sharedDefaults.string(forKey: key) ?? defaultValue
subject = CurrentValueSubject<String, Never>(currentValue)
}

func uriWithoutTrailingSlashes(_ hostUri: String) -> String {
if !hostUri.hasSuffix("/") {
return hostUri
}
public var projectedValue: AnyPublisher<String, Never> {
subject.eraseToAnyPublisher()
}

return String(hostUri[..<hostUri.index(before: hostUri.endIndex)])
private func uriWithoutTrailingSlashes(_ hostUri: String) -> String {
if hostUri.hasSuffix("/") {
return String(hostUri[..<hostUri.index(before: hostUri.endIndex)])
}
return hostUri
}
}

Expand All @@ -82,7 +107,6 @@ public enum Preferences {
@UserDefault("password", defaultValue: "test") public static var password: String
@UserDefault("alwaysSendCreds", defaultValue: false) public static var alwaysSendCreds: Bool
@UserDefault("ignoreSSL", defaultValue: false) public static var ignoreSSL: Bool
// @UserDefault("sitemapName", defaultValue: "watch") static public var sitemapName: String
@UserDefault("demomode", defaultValue: true) public static var demomode: Bool
@UserDefault("idleOff", defaultValue: false) public static var idleOff: Bool
@UserDefault("realTimeSliders", defaultValue: false) public static var realTimeSliders: Bool
Expand Down
12 changes: 0 additions & 12 deletions openHAB.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,6 @@
DA7224D223828D3400712D20 /* PreviewConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA7224D123828D3300712D20 /* PreviewConstants.swift */; };
DA72E1B8236DEA0900B8EF3A /* AppMessageService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA72E1B5236DEA0900B8EF3A /* AppMessageService.swift */; };
DA7649DE23FC81A20085CE46 /* Unwrap.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA7649DD23FC81A20085CE46 /* Unwrap.swift */; };
DA7E1E492230227E002AEFD8 /* OpenHABTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFDEE4161883C6A5008B26AC /* OpenHABTracker.swift */; };
DA7E1E4B2233986E002AEFD8 /* PlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA7E1E47222EB00B002AEFD8 /* PlayerView.swift */; };
DA817E7A234BF39B00C91824 /* CHANGELOG.md in Resources */ = {isa = PBXBuildFile; fileRef = DA817E79234BF39B00C91824 /* CHANGELOG.md */; };
DA88F8C622EC377200B408E5 /* ReleaseNotes.md in Resources */ = {isa = PBXBuildFile; fileRef = DA88F8C522EC377100B408E5 /* ReleaseNotes.md */; };
Expand Down Expand Up @@ -458,7 +457,6 @@
DFB2624C18830A3600D3244D /* XCTest.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = XCTest.framework; path = Library/Frameworks/XCTest.framework; sourceTree = DEVELOPER_DIR; };
DFDA3CE9193CADB200888039 /* ping.wav */ = {isa = PBXFileReference; lastKnownFileType = audio.wav; path = ping.wav; sourceTree = "<group>"; };
DFDEE3FC18831099008B26AC /* OpenHABSettingsViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OpenHABSettingsViewController.swift; sourceTree = "<group>"; };
DFDEE4161883C6A5008B26AC /* OpenHABTracker.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OpenHABTracker.swift; sourceTree = "<group>"; };
DFDF452E1932032B00A6E581 /* OpenHABLegalViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OpenHABLegalViewController.swift; sourceTree = "<group>"; };
DFDF45301932042B00A6E581 /* legal.rtf */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.rtf; path = legal.rtf; sourceTree = "<group>"; };
DFE10413197415F900D94943 /* Security.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Security.framework; path = System/Library/Frameworks/Security.framework; sourceTree = SDKROOT; };
Expand Down Expand Up @@ -954,7 +952,6 @@
DFDEE3FE1883228C008B26AC /* Models */,
DF4B83FD18857FA100F34902 /* UI */,
DF4B84051885AD4600F34902 /* Images */,
DFDEE3FF18832293008B26AC /* Util */,
1224F78B228A89E300750965 /* Watch */,
DFB2623118830A3600D3244D /* Supporting Files */,
938BF9C724EFCCC000E6B52F /* Resources */,
Expand Down Expand Up @@ -985,14 +982,6 @@
name = Models;
sourceTree = "<group>";
};
DFDEE3FF18832293008B26AC /* Util */ = {
isa = PBXGroup;
children = (
DFDEE4161883C6A5008B26AC /* OpenHABTracker.swift */,
);
name = Util;
sourceTree = "<group>";
};
DFFD8FCE18EDBD30003B502A /* Util */ = {
isa = PBXGroup;
children = (
Expand Down Expand Up @@ -1456,7 +1445,6 @@
files = (
93B7B33128018301009EB296 /* Intents.intentdefinition in Sources */,
DA7E1E4B2233986E002AEFD8 /* PlayerView.swift in Sources */,
DA7E1E492230227E002AEFD8 /* OpenHABTracker.swift in Sources */,
65570A7D2476D16A00D524EA /* OpenHABWebViewController.swift in Sources */,
DAF0A28B2C56E3A300A14A6A /* RollershutterCell.swift in Sources */,
DF06F1FC18FEC2020011E7B9 /* ColorPickerViewController.swift in Sources */,
Expand Down
Loading

0 comments on commit 4ea7db0

Please sign in to comment.