diff --git a/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift b/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift new file mode 100644 index 00000000..9e8fa27f --- /dev/null +++ b/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift @@ -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 + } + } +} diff --git a/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift b/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift index d030bbb3..c36b40c2 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift @@ -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 { - let key: String - let defaultValue: T + private let key: String + private let defaultValue: T + private let subject: CurrentValueSubject 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(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 { + subject.eraseToAnyPublisher() + } +} @propertyWrapper public struct UserDefaultURL { - let key: String - let defaultValue: String + private let key: String + private let defaultValue: String + private let subject: CurrentValueSubject 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(currentValue) } - func uriWithoutTrailingSlashes(_ hostUri: String) -> String { - if !hostUri.hasSuffix("/") { - return hostUri - } + public var projectedValue: AnyPublisher { + subject.eraseToAnyPublisher() + } - return String(hostUri[.. String { + if hostUri.hasSuffix("/") { + return String(hostUri[..() private lazy var webViewController: OpenHABWebViewController = { let storyboard = UIStoryboard(name: "Main", bundle: Bundle.main) @@ -98,7 +99,7 @@ class OpenHABRootViewController: UIViewController { // save this so we know if its changed later isDemoMode = Preferences.demomode switchToSavedView() - + setupTracker() // ready for push notifications NotificationCenter.default.addObserver(self, selector: #selector(handleApnsMessage(notification:)), name: .apnsReceived, object: nil) // check if we were launched with a notification @@ -118,6 +119,35 @@ class OpenHABRootViewController: UIViewController { } } + fileprivate func setupTracker() { + Publishers.CombineLatest( + Preferences.$localUrl, + Preferences.$remoteUrl + ) + .sink { (localUrl, remoteUrl) in + print("Local URL: \(localUrl), Remote URL: \(remoteUrl)") + let connection1 = ConnectionObject( + url: localUrl, + priority: 0 + ) + let connection2 = ConnectionObject( + url: remoteUrl, + priority: 1 + ) + NetworkTracker.shared.startTracking(connectionObjects: [connection1, connection2]) + } + .store(in: &cancellables) + + NetworkTracker.shared.$activeServer + .receive(on: DispatchQueue.main) + .sink { [weak self] activeServer in + if let activeServer { + self?.appData?.openHABRootUrl = activeServer.url + } + } + .store(in: &cancellables) + } + fileprivate func setupSideMenu() { let hamburgerButtonItem: UIBarButtonItem let imageConfig = UIImage.SymbolConfiguration(textStyle: .largeTitle) @@ -302,14 +332,11 @@ class OpenHABRootViewController: UIViewController { if components.count == 2 { let itemName = String(components[0]) let itemCommand = String(components[1]) - - var cancelable: AnyCancellable? - // makeConnectable() + connect() allows us to reference the cancelable var within our closure - let state = OpenHABTracker.shared.$state.makeConnectable() - // this will be called imediately after connecting for the initial state, otherwise it will wait for the state to change - cancelable = state - .sink { newState in - if let openHABUrl = newState.openHABUrl?.absoluteString { + // This will only fire onece since we do not retain the return cancelable + _ = NetworkTracker.shared.$activeServer + .receive(on: DispatchQueue.main) + .sink { activeServer in + if let openHABUrl = activeServer?.url { os_log("Sending comand", log: .default, type: .error) let client = HTTPClient(username: Preferences.username, password: Preferences.password) client.doPost(baseURLs: [openHABUrl], path: "/rest/items/\(itemName)", body: itemCommand) { data, _, error in @@ -323,9 +350,7 @@ class OpenHABRootViewController: UIViewController { } } } - cancelable?.cancel() } - _ = state.connect() } } @@ -383,13 +408,11 @@ class OpenHABRootViewController: UIViewController { // nothing } - var cancelable: AnyCancellable? - // makeConnectable() + connect() allows us to reference the cancelable var within our closure - let state = OpenHABTracker.shared.$state.makeConnectable() - // this will be called imediately after connecting for the initial state, otherwise it will wait for the state to change - cancelable = state - .sink { newState in - if let openHABUrl = newState.openHABUrl?.absoluteString { + // This will only fire onece since we do not retain the return cancelable + _ = NetworkTracker.shared.$activeServer + .receive(on: DispatchQueue.main) + .sink { activeServer in + if let openHABUrl = activeServer?.url { os_log("Sending comand", log: .default, type: .error) let client = HTTPClient(username: Preferences.username, password: Preferences.password) client.doPost(baseURLs: [openHABUrl], path: "/rest/rules/rules/\(uuid)/runnow", body: jsonString) { data, _, error in @@ -403,9 +426,7 @@ class OpenHABRootViewController: UIViewController { } } } - cancelable?.cancel() } - _ = state.connect() } func showSideMenu() { diff --git a/openHAB/OpenHABSitemapViewController.swift b/openHAB/OpenHABSitemapViewController.swift index 56fedd93..6afc4e41 100644 --- a/openHAB/OpenHABSitemapViewController.swift +++ b/openHAB/OpenHABSitemapViewController.swift @@ -165,15 +165,23 @@ class OpenHABSitemapViewController: OpenHABViewController, GenericUITableViewCel UIApplication.shared.isIdleTimerDisabled = true } - OpenHABTracker.shared.$progress - .dropFirst() // This ensures the closure only gets called on actual changes - .sink { message in - os_log("OpenHABSitemapViewController %{PUBLIC}@", log: .viewCycle, type: .info, message) - self.showPopupMessage(seconds: 1.5, title: NSLocalizedString("connecting", comment: ""), message: message, theme: .info) + NetworkTracker.shared.$status + .receive(on: DispatchQueue.main) + .sink { status in + os_log("OpenHABViewController tracker status %{PUBLIC}@", log: .viewCycle, type: .info, status.rawValue) + switch status { + case .connecting: + self.showPopupMessage(seconds: 1.5, title: NSLocalizedString("connecting", comment: ""), message: "", theme: .info) + case .connectionFailed: + os_log("Tracking error", log: .viewCycle, type: .info) + self.showPopupMessage(seconds: 60, title: NSLocalizedString("error", comment: ""), message: NSLocalizedString("network_not_available", comment: ""), theme: .error) + case _: + break + } } .store(in: &trackerCancellables) - var stateWatcher = OpenHABTracker.shared.$state.eraseToAnyPublisher() + var activeServerWatcher = NetworkTracker.shared.$activeServer.eraseToAnyPublisher() // if pageUrl == "" it means we are the first opened OpenHABSitemapViewController if pageUrl == "" { // Set self as root view controller @@ -185,7 +193,7 @@ class OpenHABSitemapViewController: OpenHABViewController, GenericUITableViewCel os_log("OpenHABSitemapViewController pageUrl is empty, this is first launch", log: .viewCycle, type: .info) } else { // we only want to our watcher to notify us about changes, and not the inital value - stateWatcher = stateWatcher.dropFirst().eraseToAnyPublisher() + activeServerWatcher = activeServerWatcher.dropFirst().eraseToAnyPublisher() if !pageNetworkStatusChanged() { os_log("OpenHABSitemapViewController pageUrl = %{PUBLIC}@", log: .notifications, type: .info, pageUrl) loadPage(false) @@ -195,17 +203,12 @@ class OpenHABSitemapViewController: OpenHABViewController, GenericUITableViewCel } } // listen for network changes, if stateWatcher.dropFirst() was NOT called, then this will exectue imediately with current values and then again if the network changes, otherwise it will be called on changes only. - stateWatcher - .sink { newState in - if let error = newState.error { - os_log("Tracking error: %{PUBLIC}@", log: .viewCycle, type: .info, error.localizedDescription) - self.showPopupMessage(seconds: 60, title: NSLocalizedString("error", comment: ""), message: error.localizedDescription, theme: .error) - return - } - - if let url = newState.openHABUrl { - os_log("OpenHABSitemapViewController tracker URL %{PUBLIC}@", log: .viewCycle, type: .info, url.absoluteString) - self.openHABRootUrl = url.absoluteString + activeServerWatcher + .receive(on: DispatchQueue.main) + .sink { activeServer in + if let activeServer { + os_log("OpenHABSitemapViewController tracker URL %{PUBLIC}@", log: .viewCycle, type: .info, activeServer.url) + self.openHABRootUrl = activeServer.url self.selectSitemap() } } @@ -469,13 +472,12 @@ class OpenHABSitemapViewController: OpenHABViewController, GenericUITableViewCel // This is mainly used for navigting to a specific sitemap and path from notifications func pushSitemap(name: String, path: String?) { - var cancelable: AnyCancellable? - // makeConnectable() + connect() allows us to reference the cancelable var within our closure - let state = OpenHABTracker.shared.$state.makeConnectable() // this will be called imediately after connecting for the initial state, otherwise it will wait for the state to change - cancelable = state - .sink { [weak self] newState in - if let openHABUrl = newState.openHABUrl?.absoluteString, let self { + // since we do not reference the sink cancelable, this will only fire once + _ = NetworkTracker.shared.$activeServer + .receive(on: DispatchQueue.main) + .sink { [weak self] activeServer in + if let openHABUrl = activeServer?.url, let self { os_log("pushSitemap: pushing page", log: .default, type: .error) let newViewController = (storyboard?.instantiateViewController(withIdentifier: "OpenHABPageViewController") as? OpenHABSitemapViewController)! if let path { @@ -486,12 +488,7 @@ class OpenHABSitemapViewController: OpenHABViewController, GenericUITableViewCel newViewController.openHABRootUrl = openHABUrl navigationController?.pushViewController(newViewController, animated: true) } - cancelable?.cancel() } - // add it here just in case our view leaves and this is still running - cancelable?.store(in: &trackerCancellables) - // _ = removes warning about unused results. - _ = state.connect() } // load app settings diff --git a/openHAB/OpenHABTracker.swift b/openHAB/OpenHABTracker.swift deleted file mode 100644 index ed484d2a..00000000 --- a/openHAB/OpenHABTracker.swift +++ /dev/null @@ -1,327 +0,0 @@ -// 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 Combine -import Foundation -import OpenHABCore -import os.log -import SystemConfiguration - -class OpenHABTrackerState { - var openHABUrl: URL? - var openHABVersion: Int? - var error: Error? - - init(openHABUrl: URL? = nil, openHABVersion: Int? = nil, error: Error? = nil) { - self.openHABUrl = openHABUrl - self.openHABVersion = openHABVersion - self.error = error - } -} - -class OpenHABTracker: NSObject { - static var shared = OpenHABTracker() - @Published var state: OpenHABTrackerState = .init() - @Published var progress = "" - private var oldReachabilityStatus: NetworkReachabilityManager.NetworkReachabilityStatus? - private let reach = NetworkReachabilityManager() - private var openHABDemoMode = false - private var openHABLocalUrl = "" - private var openHABRemoteUrl = "" - private var netService: NetService? - private var restartTimer: Timer? - - var appData: OpenHABDataObject? { - AppDelegate.appDelegate.appData - } - - override private init() { - super.init() - NotificationCenter.default.addObserver(self, selector: #selector(restart), name: NSNotification.Name("org.openhab.preferences.saved"), object: nil) - start() - } - - @objc - func restart() { - reach?.stopListening() - start() - } - - private func start() { - openHABDemoMode = Preferences.demomode - openHABLocalUrl = Preferences.localUrl - openHABRemoteUrl = Preferences.remoteUrl - - #if DEBUG - // always activate demo mode for UITest - if ProcessInfo.processInfo.environment["UITest"] != nil { - openHABDemoMode = true - } - #endif - - // Start NetworkReachabilityManager.Listener - oldReachabilityStatus = reach?.status - reach?.startListening { [weak self] status in - guard let self else { return } - let nStatus = status - // use a timer to prevent bouncing/flapping around when switching between wifi, vpn, and wwan - restartTimer?.invalidate() - restartTimer = Timer.scheduledTimer(withTimeInterval: 3.0, repeats: false) { _ in - if nStatus != self.oldReachabilityStatus { - if let oldReachabilityStatus = self.oldReachabilityStatus { - os_log("OpenHABTracker Network status changed from %{PUBLIC}@ to %{PUBLIC}@", log: OSLog.remoteAccess, type: .info, self.string(from: oldReachabilityStatus) ?? "", self.string(from: nStatus) ?? "") - } - self.oldReachabilityStatus = nStatus - if self.isNetworkConnected() { - self.restart() - } - } - } - } - - // Check if any network is available - if isNetworkConnected() { - // Check if demo mode is switched on in preferences - if openHABDemoMode { - os_log("OpenHABTracker demo mode preference is on", log: .default, type: .info) - tryDemoMode() - } else { - if isNetworkWiFi(), openHABLocalUrl.isEmpty { - startDiscovery() - } else { - os_log("OpenHABTracker network trying all", log: .default, type: .info) - tryAll() - } - } - } else { - os_log("OpenHABTracker network not available", log: .default, type: .info) - state = OpenHABTrackerState(openHABUrl: nil, openHABVersion: nil, error: errorMessage("network_not_available")) - } - } - - private func tryDiscoveryUrl(_ discoveryUrl: URL?) { - progress = NSLocalizedString("connecting_discovered", comment: "") - tryUrl(discoveryUrl) - } - - private func tryDemoMode() { - progress = NSLocalizedString("running_demo_mode", comment: "") - tryUrl(URL(staticString: "https://demo.openhab.org")) - } - - /// Attemps to connect to the URL and get the openHAB version - /// - Parameter tryUrl: Completes with the url and version of openHAB that succeeded, or an Error object if failed - private func tryUrl(_ tryUrl: URL?) { - getServerInfoForUrl(tryUrl) { url, version, error in - if let error { - self.state = OpenHABTrackerState(openHABUrl: nil, openHABVersion: nil, error: error) - } else { - self.appData?.openHABVersion = version - self.appData?.openHABRootUrl = url?.absoluteString ?? "" - self.state = OpenHABTrackerState(openHABUrl: url, openHABVersion: version, error: nil) - } - } - } - - /// Attemps to connect in parallel to the remote and local URLs if configured, the first URL to succeed wins - private func tryAll() { - var urls = [String: Double]() - if !openHABLocalUrl.isEmpty { - urls[openHABLocalUrl] = 0.0 - } - if !openHABRemoteUrl.isEmpty { - urls[openHABRemoteUrl] = openHABLocalUrl.isEmpty ? 0 : 1.5 - } - if urls.isEmpty { - state = OpenHABTrackerState(openHABUrl: nil, openHABVersion: nil, error: errorMessage("error")) - return - } - progress = NSLocalizedString("connecting", comment: "") - tryUrls(urls) { url, version, error in - if let error { - os_log("OpenHABTracker failed %{PUBLIC}@", log: .default, type: .info, error.localizedDescription) - self.state = OpenHABTrackerState(openHABUrl: nil, openHABVersion: nil, error: error) - } else { - self.appData?.openHABVersion = version - self.appData?.openHABRootUrl = url?.absoluteString ?? "" - self.state = OpenHABTrackerState(openHABUrl: url, openHABVersion: version, error: nil) - } - } - } - - /// Tries to connect in parallel to all URL's passed in and completes when either the first requests succeedes, or all fail. - /// - Parameters: - /// - urls: Tuple of String URLS and a request Delay value - /// - completion: Completes with the url and version of openHAB that succeeded, or an Error object if all failed - private func tryUrls(_ urls: [String: Double], completion: @escaping (URL?, Int, Error?) -> Void) { - var isRequestCompletedSuccessfully = false - // request in flight - var requests = [URL: DataRequest]() - // timers that have yet to be executed - var timers = [URL: Timer]() - for (urlString, delay) in urls { - let url = URL(string: urlString)! - let restUrl = URL(string: "rest/", relativeTo: url)! - let timer = Timer.scheduledTimer(withTimeInterval: delay, repeats: false) { _ in - let request = NetworkConnection.shared.manager.request(restUrl, method: .get) - .validate() - requests[url] = request - // remove us from the outstanding timer list - timers.removeValue(forKey: url) - request.responseData { response in - // remove us from the outstanding request list - requests.removeValue(forKey: url) - os_log("OpenHABTracker response for URL %{PUBLIC}@", log: .notifications, type: .error, url.absoluteString) - switch response.result { - case let .success(data): - let version = self.getServerInfoFromData(data: data) - if version > 0, !isRequestCompletedSuccessfully { - isRequestCompletedSuccessfully = true - // cancel any timers not yet fired - timers.values.forEach { $0.invalidate() } - // cancel any requests still in flight - requests.values.forEach { $0.cancel() } - completion(url, version, nil) - } - case let .failure(error): - os_log("OpenHABTracker request failure %{PUBLIC}@", log: .notifications, type: .error, error.localizedDescription) - } - // check if we are the last attempt - if !isRequestCompletedSuccessfully, requests.isEmpty, timers.isEmpty { - os_log("OpenHABTracker last response", log: .notifications, type: .error) - completion(nil, 0, self.errorMessage("network_not_available")) - } - } - request.resume() - } - timers[url] = timer - RunLoop.main.add(timer, forMode: .common) - } - } - - /// Attempts to parse the data response from a request and determine if its an openHAB server and its server version - /// - Parameter data: request data - /// - Returns: Version of openHAB or -1 if not an openHAB server - private func getServerInfoFromData(data: Data) -> Int { - do { - let serverProperties = try data.decoded(as: OpenHABServerProperties.self) - os_log("OpenHABTracker openHAB version %@", log: .remoteAccess, type: .info, serverProperties.version) - // 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 { - os_log("OpenHABTracker Could not decode response", log: .notifications, type: .error) - return -1 - } - } - - /// Attempts to connect to a URL and determine its server version - /// - Parameters: - /// - url: URL of the openHAB server - /// - completion: Completes with the url and version of openHAB that succeeded, or an Error object if failed - private func getServerInfoForUrl(_ url: URL?, completion: @escaping (URL?, Int, Error?) -> Void) { - let strUrl = url?.absoluteString ?? "" - os_log("OpenHABTracker getServerInfo, trying: %{PUBLIC}@", log: .default, type: .info, strUrl) - NetworkConnection.tracker(openHABRootUrl: strUrl) { response in - os_log("OpenHABTracker getServerInfo, recieved data for URL: %{PUBLIC}@", log: .default, type: .info, strUrl) - switch response.result { - case let .success(data): - let version = self.getServerInfoFromData(data: data) - if version > 0 { - completion(url, version, nil) - } else { - completion(url, 0, self.errorMessage("error")) - } - case let .failure(error): - os_log("OpenHABTracker getServerInfo ERROR for %{PUBLIC}@ : %{PUBLIC}@ %d", log: .remoteAccess, type: .error, strUrl, error.localizedDescription, response.response?.statusCode ?? 0) - completion(url, 0, error) - } - } - } - - private func startDiscovery() { - os_log("OpenHABTracking starting Bonjour discovery", log: .default, type: .info) - // multicastDelegate.invoke { $0.openHABTrackingProgress(NSLocalizedString("discovering_oh", comment: "")) } - progress = NSLocalizedString("discovering_oh", comment: "") - netService = NetService(domain: "local.", type: "_openhab-server-ssl._tcp.", name: "openHAB-ssl") - netService!.delegate = self - netService!.resolve(withTimeout: 5.0) - } - - func normalizeUrl(_ url: String?) -> String? { - if let url, url.hasSuffix("/") { - return String(url.dropLast()) - } - return url - } - - func isNetworkConnected() -> Bool { - reach?.isReachable ?? false - } - - func isNetworkWiFi() -> Bool { - reach?.isReachableOnEthernetOrWiFi ?? false - } - - func string(from status: NetworkReachabilityManager.NetworkReachabilityStatus) -> String? { - switch status { - case .unknown, .notReachable: - "unreachable" - case let .reachable(connectionType): - connectionType == .ethernetOrWiFi ? "WiFi" : "WWAN" - } - } - - func errorMessage(_ message: String) -> NSError { - var errorDetail: [AnyHashable: Any] = [:] - errorDetail[NSLocalizedDescriptionKey] = NSLocalizedString(message, comment: "") - return NSError(domain: "openHAB", code: 101, userInfo: errorDetail as? [String: Any]) - } -} - -extension OpenHABTracker: NetServiceDelegate, NetServiceBrowserDelegate { - // NSNetService delegate methods for publication - func netServiceDidResolveAddress(_ resolvedNetService: NetService) { - func getStringIp(fromAddressData dataIn: Data?) -> String? { - var ipString: String? - let data = dataIn! as NSData - let socketAddress: sockaddr_in = data.castToCPointer() - ipString = String(cString: inet_ntoa(socketAddress.sin_addr), encoding: .ascii) - return ipString - } - - guard let data = resolvedNetService.addresses?.first else { return } - let resolvedComponents: URLComponents = { - var components = URLComponents() - components.host = getStringIp(fromAddressData: data) - components.scheme = "https" - components.port = resolvedNetService.port - return components - }() - - let openhabUrl = "\(resolvedComponents.url!)" - os_log("OpenHABTracker discovered:%{PUBLIC}@ ", log: OSLog.remoteAccess, type: .info, openhabUrl) - tryDiscoveryUrl(resolvedComponents.url) - } - - func netService(_ netService: NetService, didNotResolve errorDict: [String: NSNumber]) { - os_log("OpenHABTracker discovery didn't resolve openHAB", log: .default, type: .info) - tryAll() - } -} - -extension NSData { - func castToCPointer() -> T { - let mem = UnsafeMutablePointer.allocate(capacity: MemoryLayout.size) - getBytes(mem, length: MemoryLayout.size) - return mem.move() - } -} diff --git a/openHAB/OpenHABWebViewController.swift b/openHAB/OpenHABWebViewController.swift index 46154c6c..b9678e88 100644 --- a/openHAB/OpenHABWebViewController.swift +++ b/openHAB/OpenHABWebViewController.swift @@ -21,7 +21,6 @@ class OpenHABWebViewController: OpenHABViewController { private var currentTarget = "" private var openHABTrackedRootUrl = "" private var hideNavBar = false - private var tracker: OpenHABTracker? private var activityIndicator: UIActivityIndicatorView! private var observation: NSKeyValueObservation? private var sseTimer: Timer? @@ -73,27 +72,31 @@ class OpenHABWebViewController: OpenHABViewController { navigationController?.setNavigationBarHidden(hideNavBar, animated: animated) navigationController?.navigationBar.prefersLargeTitles = false parent?.navigationItem.title = "Main View" - OpenHABTracker.shared.$state - .sink { newState in - if let error = newState.error { - os_log("Tracking error: %{PUBLIC}@", log: .viewCycle, type: .error, error.localizedDescription) - self.pageLoadError(message: error.localizedDescription) - return - } - - if let url = newState.openHABUrl { - os_log("OpenHABWebViewController openHAB URL = %{PUBLIC}@", log: .remoteAccess, type: .info, "\(url.absoluteString)") - self.openHABTrackedRootUrl = url.absoluteString + NetworkTracker.shared.$activeServer + .receive(on: DispatchQueue.main) + .sink { activeServer in + if let activeServer { + os_log("OpenHABWebViewController openHAB URL = %{PUBLIC}@", log: .remoteAccess, type: .info, "\(activeServer.url)") + self.openHABTrackedRootUrl = activeServer.url self.loadWebView(force: false) } } .store(in: &trackerCancellables) - OpenHABTracker.shared.$progress.sink { message in - os_log("OpenHABViewController %{PUBLIC}@", log: .viewCycle, type: .info, message) - self.showPopupMessage(seconds: 1.5, title: NSLocalizedString("connecting", comment: ""), message: message, theme: .info) - } - .store(in: &trackerCancellables) + NetworkTracker.shared.$status + .receive(on: DispatchQueue.main) + .sink { status in + os_log("OpenHABViewController tracker status %{PUBLIC}@", log: .viewCycle, type: .info, status.rawValue) + switch status { + case .connecting: + self.showPopupMessage(seconds: 1.5, title: NSLocalizedString("connecting", comment: ""), message: "", theme: .info) + case .connectionFailed: + self.pageLoadError(message: NSLocalizedString("network_not_available", comment: "")) + case _: + break + } + } + .store(in: &trackerCancellables) startTracker() } @@ -203,8 +206,7 @@ class OpenHABWebViewController: OpenHABViewController { currentTarget = "" clearExistingPage() startTracker() - // force a network rescan - OpenHABTracker.shared.restart() + loadWebView(force: true) } override func viewName() -> String { @@ -304,9 +306,6 @@ extension OpenHABWebViewController: WKScriptMessageHandler { os_log("WKScriptMessage sseConnected is false", log: OSLog.remoteAccess, type: .info) sseTimer?.invalidate() sseTimer = Timer.scheduledTimer(withTimeInterval: 10.0, repeats: false) { _ in - self.sseTimer = Timer.scheduledTimer(withTimeInterval: 20.0, repeats: false) { _ in - self.reloadView() - } self.showPopupMessage(seconds: 20, title: NSLocalizedString("connecting", comment: ""), message: "", theme: .error) self.acceptsCommands = false }